diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4f31bfa7..986fa50a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ All notable changes to lean-ctx are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/). -## [Unreleased] +## [3.10.0] — 2026-08-29 ### Added — context-budget transparency (`tools health`) +- **Portable P6 checkpoint packages** — `ctxpkg` v2 can carry bounded, + canonical checkpoint state with explicit manifest-layer coherence, + credential and non-portable-path rejection, cross-field digest binding, and + an opt-in SDK seed path; generic loaders continue to fail closed. + - **Foreign MCP server audit** — `tools health` now cross-references every non-lean-ctx MCP server the client loads (local config, project `.mcp.json`, and observed connections incl. claude.ai connectors) with recorded diff --git a/docs/specs/context-package-v2.md b/docs/specs/context-package-v2.md index 631f7acad9..1050e90527 100644 --- a/docs/specs/context-package-v2.md +++ b/docs/specs/context-package-v2.md @@ -17,6 +17,24 @@ package substrate remain distinct until a versioned composition/migration contract exists. See the internal [Context Workspace & `.ctxpkg` Plan](../internal/vision/07-CONTEXT-WORKSPACE-CTXPKG-PLAN.md). +## Additive checkpoint layer + +Research v2 reserves the explicit manifest layer `checkpoint` together with +`content.checkpoint` using envelope contract +`leanctx.ctxpkg-checkpoint/v1`. Both must be present together. The package kind +remains `context` and `manifest.schema_version` remains `2`. + +Checkpoint-critical semantics live only inside authenticated `content` bytes: +the raw canonical content hash feeds package integrity, which is bound by the +existing Ed25519 signature message. Checkpoint ID, logical-state digest, +content hash, package digest and signature remain distinct identities. + +Pre-extension typed readers reject the unknown `checkpoint` layer. Generic +package loading also rejects checkpoint packages; a checkpoint-aware Product +admission path must explicitly separate verification, signer trust, install, +seed and restore. Existing v1/v2 packages omit the new optional member and keep +their prior bytes and behavior. + ## Research direction Earlier work explored a richer package shape for reusable context assets, diff --git a/docs/specs/context-package-v2.schema.json b/docs/specs/context-package-v2.schema.json index 5f6680c8b4..d1c2f2d281 100644 --- a/docs/specs/context-package-v2.schema.json +++ b/docs/specs/context-package-v2.schema.json @@ -66,7 +66,7 @@ "type": "array", "items": { "type": "string", - "enum": ["knowledge", "graph", "session", "patterns", "gotchas"] + "enum": ["knowledge", "graph", "session", "patterns", "gotchas", "checkpoint"] }, "uniqueItems": true, "description": "Legacy v1 layer indicators" @@ -96,9 +96,86 @@ "session": { "$ref": "#/$defs/SessionLayer" }, "patterns": { "$ref": "#/$defs/PatternsLayer" }, "gotchas": { "$ref": "#/$defs/GotchasLayer" }, - "context_graph": { "$ref": "#/$defs/ContextGraph" } + "context_graph": { "$ref": "#/$defs/ContextGraph" }, + "checkpoint": { "$ref": "#/$defs/CheckpointPackageContentV1" } } }, + "CheckpointPackageContentV1": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "checkpoint", "non_portable_fields"], + "properties": { + "schema_version": { "const": "leanctx.ctxpkg-checkpoint/v1" }, + "checkpoint": { "$ref": "#/$defs/ContextCheckpointV2" }, + "migration_provenance": { "$ref": "#/$defs/SnapshotV1MigrationProvenance" }, + "non_portable_fields": { + "type": "array", + "items": { "type": "string", "maxLength": 1024 }, + "uniqueItems": true, + "maxItems": 256 + } + } + }, + "ContextCheckpointV2": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "checkpoint_id", "workspace_id", "state_digest", + "state_schema_version", "workspace_state_ref", "logical_state", + "source_anchors", "recovery_refs", "package_pins", + "package_lock_digest", "policy_digest", "project_context_digest", + "lineage", "engine_identity", "sdk_contract", "envelope_digest" + ], + "properties": { + "schema_version": { "const": "leanctx.context-checkpoint/v2" }, + "checkpoint_id": { "type": "string", "format": "uuid" }, + "workspace_id": { "type": "string", "format": "uuid" }, + "state_digest": { "$ref": "#/$defs/PrefixedSha256" }, + "state_schema_version": { "const": "leanctx.workspace.state/v1" }, + "workspace_state_ref": { + "type": "string", + "pattern": "^event:sha256:[0-9a-f]{64}$" + }, + "logical_state": { "type": "object", "maxProperties": 7 }, + "source_anchors": { "type": "array", "maxItems": 128 }, + "recovery_refs": { "type": "array", "maxItems": 4096 }, + "package_pins": { "type": "array", "maxItems": 128 }, + "package_lock_digest": { + "oneOf": [{ "$ref": "#/$defs/PrefixedSha256" }, { "type": "null" }] + }, + "policy_digest": { "$ref": "#/$defs/PrefixedSha256" }, + "project_context_digest": { "$ref": "#/$defs/PrefixedSha256" }, + "lineage": { "type": "object", "maxProperties": 3 }, + "engine_identity": { "type": "object", "maxProperties": 3 }, + "sdk_contract": { "const": "leanctx-product-sdk-research/p6" }, + "envelope_digest": { "$ref": "#/$defs/PrefixedSha256" } + } + }, + "SnapshotV1MigrationProvenance": { + "type": "object", + "additionalProperties": false, + "required": [ + "origin", "legacy_snapshot_id", "legacy_snapshot_digest", + "migration_contract", "checkpoint_id", "state_digest", "limitations" + ], + "properties": { + "origin": { "const": "SnapshotV1" }, + "legacy_snapshot_id": { "type": "string", "minLength": 1, "maxLength": 512 }, + "legacy_snapshot_digest": { "$ref": "#/$defs/PrefixedSha256" }, + "migration_contract": { "const": "leanctx.snapshot-v1-migration/v1" }, + "checkpoint_id": { "type": "string", "format": "uuid" }, + "state_digest": { "$ref": "#/$defs/PrefixedSha256" }, + "limitations": { + "type": "array", + "items": { "type": "string", "maxLength": 2048 }, + "maxItems": 64 + } + } + }, + "PrefixedSha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, "ContextGraph": { "type": "object", "required": ["format", "nodes", "edges"], diff --git a/packages/lean-ctx-bin/package.json b/packages/lean-ctx-bin/package.json index 6767d7b209..81df3b567f 100644 --- a/packages/lean-ctx-bin/package.json +++ b/packages/lean-ctx-bin/package.json @@ -1,6 +1,6 @@ { "name": "lean-ctx-bin", - "version": "3.9.20", + "version": "3.10.0", "description": "LeanCTX \u2014 a local Context SDK for AI agents. Select, shape, reuse, recover, and measure context before inference. No Rust required.", "keywords": [ "lean-ctx", diff --git a/packages/pi-lean-ctx/package.json b/packages/pi-lean-ctx/package.json index d122a86699..f9aa88ab33 100644 --- a/packages/pi-lean-ctx/package.json +++ b/packages/pi-lean-ctx/package.json @@ -1,6 +1,6 @@ { "name": "pi-lean-ctx", - "version": "3.9.20", + "version": "3.10.0", "description": "Pi Coding Agent extension — routes bash/read/grep/find/ls through lean-ctx for strong token savings. The embedded MCP bridge (on by default) adds a persistent session cache so unchanged re-reads cost ~13 tokens.", "keywords": [ "pi-package", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ddb9865635..70750152fc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2041,7 +2041,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lean-ctx" -version = "3.9.20" +version = "3.10.0" dependencies = [ "anyhow", "argon2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index e5e6f38aaf..5ce974c819 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,7 +21,7 @@ default-members = ["."] [package] name = "lean-ctx" -version = "3.9.20" +version = "3.10.0" edition = "2024" autobins = false description = "Local context engine, CLI, MCP server, and proxy for AI agents." diff --git a/rust/src/cli/pack_cmd/checkpoint.rs b/rust/src/cli/pack_cmd/checkpoint.rs new file mode 100644 index 0000000000..7470ee7c27 --- /dev/null +++ b/rust/src/cli/pack_cmd/checkpoint.rs @@ -0,0 +1,189 @@ +use std::path::{Path, PathBuf}; + +use crate::core::context_package::PackageLayer; +use crate::core::context_package::content::CheckpointPackageContentV1; +use sha2::{Digest, Sha256}; + +const MAX_CHECKPOINT_INPUT_BYTES: u64 = 8 * 1024 * 1024; +const MAX_CHECKPOINT_PACKAGE_BYTES: u64 = 16 * 1024 * 1024; + +pub(super) fn cmd_pack_checkpoint_seal(args: &[String]) { + let input = flag(args, "--checkpoint"); + let output = flag(args, "--output"); + let name = flag(args, "--name"); + let version = flag(args, "--version").unwrap_or_else(|| "1.0.0".into()); + let unsigned = args.iter().any(|arg| arg == "--unsigned"); + let (Some(input), Some(output), Some(name)) = (input, output, name) else { + fail( + "Usage: lean-ctx pack checkpoint-seal --checkpoint= --output= --name= [--version=] [--unsigned]", + ); + }; + + require_bounded_regular_file( + Path::new(&input), + MAX_CHECKPOINT_INPUT_BYTES, + "checkpoint payload", + ); + let raw = std::fs::read_to_string(&input) + .unwrap_or_else(|error| fail(&format!("read checkpoint payload: {error}"))); + let checkpoint: CheckpointPackageContentV1 = serde_json::from_str(&raw) + .unwrap_or_else(|error| fail(&format!("parse checkpoint payload: {error}"))); + let (manifest, content) = crate::core::context_package::PackageBuilder::new(&name, &version) + .description("Portable ContextCheckpointV2") + .checkpoint(checkpoint) + .build() + .unwrap_or_else(|error| fail(&format!("build checkpoint package: {error}"))); + + let signing_key = if unsigned { + None + } else { + Some( + crate::core::context_package::keys::load_or_create() + .unwrap_or_else(|error| fail(&format!("signing key: {error}"))) + .0, + ) + }; + let manifest = crate::core::context_package::registry::write_checkpoint_bundle( + manifest, + content, + Path::new(&output), + signing_key.as_ref(), + ) + .unwrap_or_else(|error| fail(&format!("seal checkpoint package: {error}"))); + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema_version": "leanctx.ctxpkg-checkpoint-seal/v1", + "path": PathBuf::from(output), + "name": manifest.name, + "version": manifest.version, + "package_digest": format!("sha256:{}", manifest.integrity.sha256), + "content_hash": format!("sha256:{}", manifest.integrity.content_hash), + "signature_state": if manifest.signature.is_some() { "signed_valid" } else { "unsigned" }, + })) + .expect("seal result serializes") + ); +} + +pub(super) fn cmd_pack_checkpoint_inspect(args: &[String]) { + let file = args + .iter() + .find(|arg| !arg.starts_with("--") && arg.as_str() != "checkpoint-inspect") + .unwrap_or_else(|| fail("Usage: lean-ctx pack checkpoint-inspect ")); + require_bounded_regular_file( + Path::new(file), + MAX_CHECKPOINT_PACKAGE_BYTES, + "checkpoint package", + ); + let (manifest, checkpoint) = + crate::core::context_package::registry::read_checkpoint_bundle(Path::new(file)) + .unwrap_or_else(|error| fail(&format!("inspect checkpoint package: {error}"))); + let signature_state = if manifest.signature.is_some() { + "signed_valid" + } else { + "unsigned" + }; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema_version": "leanctx.ctxpkg-checkpoint-inspect/v1", + "package": { + "schema_version": manifest.schema_version, + "kind": manifest.kind.as_str(), + "layers": manifest.layers.iter().map(PackageLayer::as_str).collect::>(), + "name": manifest.name, + "version": manifest.version, + "package_digest": format!("sha256:{}", manifest.integrity.sha256), + "content_hash": format!("sha256:{}", manifest.integrity.content_hash), + "signature_state": signature_state, + "signer_public_key": manifest.signature.as_ref().map(|signature| &signature.public_key), + }, + "checkpoint": checkpoint, + })) + .expect("inspect result serializes") + ); +} + +fn require_bounded_regular_file(path: &Path, max_bytes: u64, label: &str) { + let metadata = std::fs::symlink_metadata(path) + .unwrap_or_else(|error| fail(&format!("stat {label}: {error}"))); + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes { + fail(&format!( + "{label} must be a bounded regular non-symlink file" + )); + } +} + +pub(super) fn cmd_pack_snapshot_v1_inspect(args: &[String]) { + use crate::core::context_snapshot::types::{ + MAX_SNAPSHOT_LEDGER_ITEMS, MAX_SNAPSHOT_LINEAGE_ITEMS, MAX_SNAPSHOT_SESSION_LIST, + }; + + let file = args + .iter() + .find(|arg| !arg.starts_with("--") && arg.as_str() != "snapshot-v1-inspect") + .unwrap_or_else(|| fail("Usage: lean-ctx pack snapshot-v1-inspect ")); + let path = Path::new(file); + let metadata = std::fs::symlink_metadata(path) + .unwrap_or_else(|error| fail(&format!("stat SnapshotV1: {error}"))); + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 8 * 1024 * 1024 + { + fail("SnapshotV1 must be a bounded regular non-symlink file"); + } + let raw = + std::fs::read(path).unwrap_or_else(|error| fail(&format!("read SnapshotV1: {error}"))); + let snapshot: crate::core::context_snapshot::ContextSnapshotV1 = serde_json::from_slice(&raw) + .unwrap_or_else(|error| fail(&format!("parse SnapshotV1: {error}"))); + if snapshot.schema_version != crate::core::contracts::CONTEXT_SNAPSHOT_V1_SCHEMA_VERSION + || snapshot.lineage.items.len() > MAX_SNAPSHOT_LINEAGE_ITEMS + || snapshot.ledger.items.len() > MAX_SNAPSHOT_LEDGER_ITEMS + || snapshot.session.as_ref().is_some_and(|session| { + session.decisions.len() > MAX_SNAPSHOT_SESSION_LIST + || session.files_touched.len() > MAX_SNAPSHOT_SESSION_LIST + }) + { + fail("SnapshotV1 schema or bounds are invalid"); + } + if !crate::core::context_snapshot::verify_snapshot(&snapshot) + .unwrap_or_else(|error| fail(&format!("verify SnapshotV1: {error}"))) + { + fail("SnapshotV1 signature or canonical identity is invalid"); + } + if snapshot.git.commit.as_ref().is_some_and(|commit| { + !(7..=64).contains(&commit.len()) || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) || snapshot.git.branch.as_ref().is_some_and(|branch| { + branch.is_empty() + || branch.len() > 255 + || branch.contains("..") + || branch.starts_with('/') + || branch.ends_with('/') + || branch.chars().any(char::is_control) + }) { + fail("SnapshotV1 git anchor is invalid"); + } + let mut hasher = Sha256::new(); + hasher.update(&raw); + let artifact_digest = crate::core::agent_identity::hex_encode(&hasher.finalize()); + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "schema_version": "leanctx.snapshot-v1-inspect/v1", + "snapshot_id": snapshot.snapshot_id, + "artifact_digest": format!("sha256:{artifact_digest}"), + "signature_state": "signed_valid", + "signer_public_key": snapshot.signature.as_ref().map(|signature| &signature.public_key), + })) + .expect("SnapshotV1 inspect result serializes") + ); +} + +fn flag(args: &[String], name: &str) -> Option { + let prefix = format!("{name}="); + args.iter() + .find_map(|arg| arg.strip_prefix(&prefix).map(str::to_string)) +} + +fn fail(message: &str) -> ! { + eprintln!("ERROR: {message}"); + std::process::exit(1) +} diff --git a/rust/src/cli/pack_cmd/mod.rs b/rust/src/cli/pack_cmd/mod.rs index 778182c4db..67521183ac 100644 --- a/rust/src/cli/pack_cmd/mod.rs +++ b/rust/src/cli/pack_cmd/mod.rs @@ -1,3 +1,4 @@ +mod checkpoint; mod management; mod package; mod pr; @@ -31,6 +32,9 @@ pub(crate) fn cmd_pack(args: &[String]) { "export" => cmd_pack_export(args), "import" => cmd_pack_import(args, &project_root), "verify" => cmd_pack_verify(args), + "checkpoint-seal" => checkpoint::cmd_pack_checkpoint_seal(args), + "checkpoint-inspect" => checkpoint::cmd_pack_checkpoint_inspect(args), + "snapshot-v1-inspect" => checkpoint::cmd_pack_snapshot_v1_inspect(args), "auto-load" => cmd_pack_auto_load(args), "publish" => cmd_pack_publish(args), "send" => cmd_pack_send(args, &project_root), @@ -60,6 +64,9 @@ fn print_usage() { \x20 export [@version] [--output=] [--sign] [--private] [--allow-secrets] Export to .{ext} file (--sign: ed25519, required for publish; --private: hidden on the hosted registry; secret scan blocks credential-shaped content unless --allow-secrets)\n\ \x20 import [--apply] Import from file\n\ \x20 verify [...] Verify integrity + signature, no install (spec \u{a7}8/\u{a7}9; exit 1 on failure)\n\ + \x20 checkpoint-seal --checkpoint= --output= --name= [--version=] [--unsigned]\n\ + \x20 checkpoint-inspect Verify and emit the open checkpoint envelope as bounded JSON\n\ + \x20 snapshot-v1-inspect Verify bounded signed SnapshotV1 migration input\n\ \x20 install [@version] [--file=] Apply package to current project\n\ \x20 install /[@version] Install from the hosted registry\n\ \x20 (ctxpkg.com; verifies sha256 + signature, pins in ctxpkg.lock,\n\ diff --git a/rust/src/core/context_package/builder.rs b/rust/src/core/context_package/builder.rs index 460ddfcb11..6e665f30e7 100644 --- a/rust/src/core/context_package/builder.rs +++ b/rust/src/core/context_package/builder.rs @@ -2,8 +2,9 @@ use chrono::Utc; use sha2::{Digest, Sha256}; use super::content::{ - GotchaExport, GotchasLayer, GraphEdgeExport, GraphLayer, GraphNodeExport, KnowledgeLayer, - PackageContent, PatternsLayer, SessionDecision, SessionFinding, SessionLayer, + CheckpointPackageContentV1, GotchaExport, GotchasLayer, GraphEdgeExport, GraphLayer, + GraphNodeExport, KnowledgeLayer, PackageContent, PatternsLayer, SessionDecision, + SessionFinding, SessionLayer, }; use super::manifest::{ CompatibilitySpec, PackageIntegrity, PackageLayer, PackageManifest, PackageProvenance, @@ -68,6 +69,11 @@ impl PackageBuilder { self } + pub(crate) fn checkpoint(mut self, checkpoint: CheckpointPackageContentV1) -> Self { + self.content.checkpoint = Some(checkpoint); + self + } + pub(crate) fn session_id(mut self, id: &str) -> Self { self.session_id = Some(id.to_string()); self @@ -364,7 +370,7 @@ impl PackageBuilder { return Err("package has no content — add at least one layer".into()); } - let is_v2 = self.content.context_graph.is_some(); + let is_v2 = self.content.context_graph.is_some() || self.content.checkpoint.is_some(); let mut layers = Vec::new(); if self.content.knowledge.is_some() { @@ -382,6 +388,9 @@ impl PackageBuilder { if self.content.gotchas.is_some() { layers.push(PackageLayer::Gotchas); } + if self.content.checkpoint.is_some() { + layers.push(PackageLayer::Checkpoint); + } let content_json = serde_json::to_string(&self.content).map_err(|e| e.to_string())?; let content_bytes = content_json.as_bytes(); diff --git a/rust/src/core/context_package/content.rs b/rust/src/core/context_package/content.rs index 77cbf2cba6..f3571927c1 100644 --- a/rust/src/core/context_package/content.rs +++ b/rust/src/core/context_package/content.rs @@ -29,6 +29,30 @@ pub(crate) struct PackageContent { /// [`super::verify::validate_kind_coherence`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub documents: Option, + /// Portable P6 checkpoint envelope. Presence is coherent IFF the manifest + /// declares the explicit `checkpoint` layer, making pre-extension readers + /// reject the package at their closed enum parse boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option, +} + +pub(crate) const CHECKPOINT_PACKAGE_SCHEMA_V1: &str = "leanctx.ctxpkg-checkpoint/v1"; +pub(crate) const MAX_CHECKPOINT_PACKAGE_BYTES: usize = 8 * 1024 * 1024; +pub(crate) const MAX_CHECKPOINT_SOURCES: usize = 128; +pub(crate) const MAX_CHECKPOINT_ENTRIES: usize = 256; +pub(crate) const MAX_CHECKPOINT_REFS: usize = 4096; +pub(crate) const MAX_CHECKPOINT_PACKAGE_PINS: usize = 128; + +/// Open mechanism-level carrier. Product lifecycle meaning remains in the +/// private SDK; Engine validates structure, bounds and cryptographic identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct CheckpointPackageContentV1 { + pub schema_version: String, + pub checkpoint: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub migration_provenance: Option, + pub non_portable_fields: Vec, } /// Distribution view of an addon (unified distribution, GH #726): the @@ -260,6 +284,9 @@ impl PackageContent { if self.documents.is_some() { n += 1; } + if self.checkpoint.is_some() { + n += 1; + } n } diff --git a/rust/src/core/context_package/loader.rs b/rust/src/core/context_package/loader.rs index bf51ce5e29..9263077f34 100644 --- a/rust/src/core/context_package/loader.rs +++ b/rust/src/core/context_package/loader.rs @@ -5,7 +5,7 @@ use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node, NodeKind}; use super::composition; use super::content::{GraphLayer, KnowledgeLayer, PackageContent, PatternsLayer, SessionLayer}; use super::graph_model::ContextGraph; -use super::manifest::PackageManifest; +use super::manifest::{PackageLayer, PackageManifest}; #[derive(Debug, Clone, Default)] pub(crate) struct LoadReport { @@ -106,6 +106,12 @@ pub(crate) fn load_package( content: &PackageContent, project_root: &str, ) -> Result { + if manifest.has_layer(PackageLayer::Checkpoint) || content.checkpoint.is_some() { + return Err( + "checkpoint packages require the explicit checkpoint-aware SDK seed path; generic load is unsupported" + .into(), + ); + } let mut report = LoadReport { package_name: manifest.name.clone(), package_version: manifest.version.clone(), diff --git a/rust/src/core/context_package/manifest.rs b/rust/src/core/context_package/manifest.rs index 2dfaec1385..c22e58ef98 100644 --- a/rust/src/core/context_package/manifest.rs +++ b/rust/src/core/context_package/manifest.rs @@ -93,6 +93,7 @@ pub(crate) enum PackageLayer { Session, Patterns, Gotchas, + Checkpoint, } impl PackageLayer { @@ -103,6 +104,7 @@ impl PackageLayer { Self::Session => "session", Self::Patterns => "patterns", Self::Gotchas => "gotchas", + Self::Checkpoint => "checkpoint", } } @@ -113,6 +115,7 @@ impl PackageLayer { Self::Session => "session.json", Self::Patterns => "patterns.json", Self::Gotchas => "gotchas.json", + Self::Checkpoint => "checkpoint.json", } } } @@ -217,6 +220,11 @@ impl PackageManifest { crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION, )); } + if self.layers.contains(&PackageLayer::Checkpoint) + && (!self.is_v2() || !self.kind.is_context()) + { + errors.push("checkpoint layer requires schema_version 2 and kind=context".into()); + } let mut seen_layers = std::collections::HashSet::new(); for layer in &self.layers { if !seen_layers.insert(layer.as_str()) { diff --git a/rust/src/core/context_package/registry.rs b/rust/src/core/context_package/registry.rs index 2a0e34b746..bcccde8a99 100644 --- a/rust/src/core/context_package/registry.rs +++ b/rust/src/core/context_package/registry.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; -use super::content::PackageContent; +use super::content::{CheckpointPackageContentV1, PackageContent}; use super::manifest::PackageManifest; const INDEX_FILE: &str = "package-index.json"; @@ -359,6 +359,67 @@ struct ExportBundle { content: PackageContent, } +pub(crate) fn write_checkpoint_bundle( + mut manifest: PackageManifest, + content: PackageContent, + output: &Path, + signing_key: Option<&ed25519_dalek::SigningKey>, +) -> Result { + super::verify::validate_kind_coherence(&manifest, &content) + .map_err(|errors| errors.join("; "))?; + let checkpoint = content + .checkpoint + .as_ref() + .ok_or("checkpoint bundle has no content.checkpoint payload")?; + if !manifest.has_layer(super::manifest::PackageLayer::Checkpoint) { + return Err("checkpoint bundle has no checkpoint manifest layer".into()); + } + if !crate::core::secret_detection::detect_secrets( + &serde_json::to_string(checkpoint).map_err(|error| error.to_string())?, + ) + .is_empty() + { + return Err("checkpoint bundle contains credential-shaped material".into()); + } + if let Some(key) = signing_key { + super::signing::sign_package(&mut manifest, &content, key); + } + let bundle = ExportBundle { + manifest: manifest.clone(), + content, + }; + let json = serde_json::to_string_pretty(&bundle).map_err(|error| error.to_string())?; + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent).map_err(|error| format!("create output dir: {error}"))?; + } + atomic_write(output, json.as_bytes())?; + let report = super::verify::verify_package_file(output)?; + if !report.valid() { + return Err(format!( + "written checkpoint package failed verification: {}", + report.errors.join("; ") + )); + } + Ok(manifest) +} + +pub(crate) fn read_checkpoint_bundle( + path: &Path, +) -> Result<(PackageManifest, CheckpointPackageContentV1), String> { + let report = super::verify::verify_package_file(path)?; + if !report.valid() { + return Err(report.errors.join("; ")); + } + let json = std::fs::read_to_string(path).map_err(|error| format!("read package: {error}"))?; + let bundle: ExportBundle = + serde_json::from_str(&json).map_err(|error| format!("parse package: {error}"))?; + let checkpoint = bundle + .content + .checkpoint + .ok_or("package has no checkpoint content")?; + Ok((bundle.manifest, checkpoint)) +} + use super::verify::{compact_json_text, extract_top_level_value_text}; /// Verify integrity against the writer's bytes: `content_text` is the exact diff --git a/rust/src/core/context_package/verify.rs b/rust/src/core/context_package/verify.rs index 20c1766908..344e3baef4 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -8,39 +8,15 @@ use sha2::{Digest, Sha256}; use std::path::Path; -use super::content::PackageContent; -use super::manifest::{PackageKind, PackageManifest}; - -/// Strip insignificant whitespace outside string literals (spec §8). -pub(crate) fn compact_json_text(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut chars = text.chars(); - let mut in_string = false; - while let Some(ch) = chars.next() { - if in_string { - out.push(ch); - match ch { - '\\' => { - if let Some(esc) = chars.next() { - out.push(esc); - } - } - '"' => in_string = false, - _ => {} - } - } else { - match ch { - '"' => { - in_string = true; - out.push(ch); - } - ' ' | '\t' | '\n' | '\r' => {} - _ => out.push(ch), - } - } - } - out -} +use super::content::{ + CHECKPOINT_PACKAGE_SCHEMA_V1, CheckpointPackageContentV1, MAX_CHECKPOINT_ENTRIES, + MAX_CHECKPOINT_PACKAGE_BYTES, MAX_CHECKPOINT_PACKAGE_PINS, MAX_CHECKPOINT_REFS, + MAX_CHECKPOINT_SOURCES, PackageContent, +}; +use super::manifest::{PackageKind, PackageLayer, PackageManifest}; + +mod text; +pub(crate) use text::compact_json_text; /// Extract the exact text of one top-level member's value from a JSON object /// document, so integrity hashing sees the writer's bytes (spec §8). @@ -174,6 +150,20 @@ pub(crate) fn validate_kind_coherence( } } } + let has_checkpoint_layer = manifest.has_layer(PackageLayer::Checkpoint); + if has_checkpoint_layer != content.checkpoint.is_some() { + errors.push( + "manifest checkpoint layer and content.checkpoint must be present together".into(), + ); + } + if let Some(checkpoint) = &content.checkpoint { + if manifest.schema_version != crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION + || manifest.kind != PackageKind::Context + { + errors.push("checkpoint content requires schema_version 2 and kind=context".into()); + } + validate_checkpoint_content(checkpoint, &mut errors); + } if errors.is_empty() { Ok(()) } else { @@ -181,6 +171,1049 @@ pub(crate) fn validate_kind_coherence( } } +fn validate_checkpoint_content(portable: &CheckpointPackageContentV1, errors: &mut Vec) { + if portable.schema_version != CHECKPOINT_PACKAGE_SCHEMA_V1 { + errors.push(format!( + "unsupported checkpoint package schema `{}`", + portable.schema_version + )); + return; + } + let Ok(encoded) = serde_json::to_vec(portable) else { + errors.push("checkpoint content is not canonical JSON".into()); + return; + }; + if encoded.len() > MAX_CHECKPOINT_PACKAGE_BYTES { + errors.push(format!( + "checkpoint content exceeds {MAX_CHECKPOINT_PACKAGE_BYTES} byte cap" + )); + return; + } + validate_checkpoint_object(&portable.checkpoint, errors); + validate_migration_provenance( + portable.migration_provenance.as_ref(), + &portable.checkpoint, + errors, + ); + + let mut absolute_paths = Vec::new(); + collect_non_portable_paths(&portable.checkpoint, "$.checkpoint", &mut absolute_paths); + absolute_paths.sort(); + absolute_paths.dedup(); + let mut declared = portable.non_portable_fields.clone(); + declared.sort(); + declared.dedup(); + if declared != portable.non_portable_fields + || declared.len() > 256 + || declared + .iter() + .any(|item| item.is_empty() || item.len() > 1024 || item.chars().any(char::is_control)) + || declared != absolute_paths + { + errors.push( + "non_portable_fields must exactly classify every machine-local absolute path".into(), + ); + } + + let portable_text = serde_json::to_string(portable).unwrap_or_default(); + if !crate::core::secret_detection::detect_secrets(&portable_text).is_empty() { + errors.push("checkpoint content contains credential-shaped material".into()); + } +} + +fn validate_checkpoint_object(value: &serde_json::Value, errors: &mut Vec) { + const KEYS: &[&str] = &[ + "schema_version", + "checkpoint_id", + "workspace_id", + "state_digest", + "state_schema_version", + "workspace_state_ref", + "logical_state", + "source_anchors", + "recovery_refs", + "package_pins", + "package_lock_digest", + "policy_digest", + "project_context_digest", + "lineage", + "engine_identity", + "sdk_contract", + "envelope_digest", + ]; + let Some(object) = value.as_object() else { + errors.push("checkpoint envelope must be an object".into()); + return; + }; + validate_exact_keys(object, KEYS, "checkpoint", errors); + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.context-checkpoint/v2") + || object + .get("state_schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.workspace.state/v1") + || object + .get("sdk_contract") + .and_then(serde_json::Value::as_str) + != Some("leanctx-product-sdk-research/p6") + { + errors.push("checkpoint contract identity is unsupported".into()); + } + for name in ["checkpoint_id", "workspace_id"] { + let valid = object + .get(name) + .and_then(serde_json::Value::as_str) + .and_then(|raw| uuid::Uuid::parse_str(raw).ok().map(|parsed| (raw, parsed))) + .is_some_and(|(raw, parsed)| parsed.hyphenated().to_string() == raw); + if !valid { + errors.push(format!("checkpoint.{name} must be a canonical UUID")); + } + } + for name in [ + "state_digest", + "policy_digest", + "project_context_digest", + "envelope_digest", + ] { + if !object.get(name).is_some_and(valid_prefixed_digest) { + errors.push(format!("checkpoint.{name} must be a sha256 digest")); + } + } + if !object.get("workspace_state_ref").is_some_and(|item| { + item.as_str().is_some_and(|raw| { + raw.starts_with("event:sha256:") + && raw.len() == 77 + && raw[13..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + }) { + errors.push("checkpoint.workspace_state_ref must bind an event digest".into()); + } + if !object + .get("package_lock_digest") + .is_some_and(|item| item.is_null() || valid_prefixed_digest(item)) + { + errors.push("checkpoint.package_lock_digest is invalid".into()); + } + + let arrays = [ + ("source_anchors", MAX_CHECKPOINT_SOURCES), + ("recovery_refs", MAX_CHECKPOINT_REFS), + ("package_pins", MAX_CHECKPOINT_PACKAGE_PINS), + ]; + for (name, cap) in arrays { + if object + .get(name) + .and_then(serde_json::Value::as_array) + .is_none_or(|items| items.len() > cap) + { + errors.push(format!("checkpoint.{name} is missing or exceeds cap {cap}")); + } + } + let Some(logical) = object + .get("logical_state") + .and_then(serde_json::Value::as_object) + else { + errors.push("checkpoint.logical_state must be an object".into()); + return; + }; + const LOGICAL_KEYS: &[&str] = &[ + "schema_version", + "workspace_id", + "policy", + "sources", + "entries", + "package_pins", + "package_lock_digest", + ]; + validate_exact_keys(logical, LOGICAL_KEYS, "checkpoint.logical_state", errors); + if logical + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.workspace.state/v1") + { + errors.push("checkpoint logical-state schema is unsupported".into()); + } + validate_workspace_policy(logical.get("policy"), errors); + validate_lineage(object.get("lineage"), object.get("workspace_id"), errors); + validate_engine_identity(object.get("engine_identity"), errors); + if logical.get("workspace_id") != object.get("workspace_id") + || logical.get("sources") != object.get("source_anchors") + || logical.get("package_pins") != object.get("package_pins") + || logical.get("package_lock_digest") != object.get("package_lock_digest") + { + errors.push("checkpoint cross-field projections disagree".into()); + } + if logical + .get("sources") + .and_then(serde_json::Value::as_array) + .is_none_or(|items| items.len() > MAX_CHECKPOINT_SOURCES) + || logical + .get("entries") + .and_then(serde_json::Value::as_array) + .is_none_or(|items| items.len() > MAX_CHECKPOINT_ENTRIES) + { + errors.push("checkpoint logical-state arrays exceed bounds".into()); + } + let source_ids = logical + .get("sources") + .and_then(serde_json::Value::as_array) + .map(|sources| { + sources + .iter() + .filter_map(|source| source.get("source_id").and_then(serde_json::Value::as_str)) + .collect::>() + }) + .unwrap_or_default(); + if let Some(sources) = logical.get("sources").and_then(serde_json::Value::as_array) { + validate_source_anchors(sources, errors); + } + if let Some(entries) = logical.get("entries").and_then(serde_json::Value::as_array) { + validate_context_entries(entries, &source_ids, errors); + let mut expected_refs = entries + .iter() + .filter_map(|entry| { + entry + .get("recovery_refs") + .and_then(serde_json::Value::as_array) + }) + .flatten() + .cloned() + .collect::>(); + expected_refs.sort_by(|left, right| left.as_str().cmp(&right.as_str())); + expected_refs.dedup(); + if object + .get("recovery_refs") + .and_then(serde_json::Value::as_array) + != Some(&expected_refs) + { + errors.push("checkpoint recovery refs disagree with context entries".into()); + } + } + if let Some(refs) = object + .get("recovery_refs") + .and_then(serde_json::Value::as_array) + { + validate_string_array( + refs, + MAX_CHECKPOINT_REFS, + 512, + true, + "checkpoint recovery refs", + errors, + ); + } + if let Some(pins) = logical + .get("package_pins") + .and_then(serde_json::Value::as_array) + { + validate_package_pins(pins, logical.get("package_lock_digest"), errors); + } + validate_domain_digest( + "leanctx.workspace.state.v1", + &serde_json::Value::Object(logical.clone()), + object.get("state_digest"), + "checkpoint.state_digest", + errors, + ); + if let Some(policy) = logical.get("policy") { + validate_domain_digest( + "leanctx.workspace.policy.v1", + policy, + object.get("policy_digest"), + "checkpoint.policy_digest", + errors, + ); + } + if let Some(entries) = logical.get("entries") { + validate_domain_digest( + "leanctx.project-context.state.v1", + entries, + object.get("project_context_digest"), + "checkpoint.project_context_digest", + errors, + ); + } + let mut unsigned = object.clone(); + unsigned.remove("envelope_digest"); + validate_domain_digest( + "leanctx.checkpoint.envelope.v2", + &serde_json::Value::Object(unsigned), + object.get("envelope_digest"), + "checkpoint.envelope_digest", + errors, + ); +} + +fn validate_workspace_policy(value: Option<&serde_json::Value>, errors: &mut Vec) { + const KEYS: &[&str] = &[ + "schema_version", + "allowed_categories", + "max_events", + "max_context_entries", + "max_entry_bytes", + "max_context_bytes", + "max_sources", + "max_sessions", + "allow_external_sources", + ]; + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push("checkpoint workspace policy must be an object".into()); + return; + }; + validate_exact_keys(object, KEYS, "checkpoint workspace policy", errors); + let Some(categories) = object + .get("allowed_categories") + .and_then(serde_json::Value::as_array) + else { + errors.push("checkpoint workspace policy categories must be an array".into()); + return; + }; + let category_values = categories + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.workspace-policy/v1") + || category_values.len() != categories.len() + || !category_values.windows(2).all(|pair| pair[0] < pair[1]) + || category_values.iter().any(|category| { + !matches!( + *category, + "facts" | "decisions" | "constraints" | "unresolved_questions" | "source_refs" + ) + }) + || [ + "max_events", + "max_context_entries", + "max_entry_bytes", + "max_context_bytes", + "max_sources", + "max_sessions", + ] + .iter() + .any(|name| { + object + .get(*name) + .and_then(serde_json::Value::as_u64) + .is_none_or(|n| n == 0) + }) + || !object + .get("allow_external_sources") + .is_some_and(serde_json::Value::is_boolean) + { + errors.push("checkpoint workspace policy is invalid".into()); + } +} + +fn validate_lineage( + value: Option<&serde_json::Value>, + workspace_id: Option<&serde_json::Value>, + errors: &mut Vec, +) { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push("checkpoint lineage must be an object".into()); + return; + }; + validate_exact_keys( + object, + &["kind", "workspace_id", "state_id"], + "checkpoint lineage", + errors, + ); + if object.get("kind").and_then(serde_json::Value::as_str) != Some("workspace") + || object.get("workspace_id") != workspace_id + || !object.get("state_id").is_some_and(valid_prefixed_digest) + { + errors.push("checkpoint lineage is invalid".into()); + } +} + +fn validate_engine_identity(value: Option<&serde_json::Value>, errors: &mut Vec) { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push("checkpoint engine identity must be an object".into()); + return; + }; + validate_exact_keys( + object, + &["interface_version", "schema_version", "transport_version"], + "checkpoint engine identity", + errors, + ); + if object + .get("interface_version") + .and_then(serde_json::Value::as_str) + != Some("1.0.0") + || object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + || object + .get("transport_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + { + errors.push("checkpoint engine identity is unsupported".into()); + } +} + +fn validate_source_anchors(sources: &[serde_json::Value], errors: &mut Vec) { + const KEYS: &[&str] = &[ + "schema_version", + "source_id", + "kind", + "canonical_id", + "revision", + "freshness", + "recovery", + "trust", + "scope", + "engine_binding", + ]; + let mut source_ids = Vec::new(); + for source in sources { + let Some(object) = source.as_object() else { + errors.push("checkpoint source anchor must be an object".into()); + continue; + }; + validate_exact_keys(object, KEYS, "checkpoint source anchor", errors); + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.source-anchor/v1") + { + errors.push("checkpoint source anchor schema is unsupported".into()); + } + let source_id = bounded_string(object.get("source_id"), 128); + let kind = object.get("kind").and_then(serde_json::Value::as_str); + if source_id.is_none() + || !matches!( + kind, + Some("filesystem" | "git" | "archive" | "api" | "custom") + ) + || bounded_string(object.get("canonical_id"), 2048).is_none() + { + errors.push("checkpoint source anchor identity is invalid".into()); + } + if let Some(source_id) = source_id { + source_ids.push(source_id); + } + validate_revision(object.get("revision"), kind, errors); + validate_freshness(object.get("freshness"), errors); + validate_recovery(object.get("recovery"), errors); + validate_trust(object.get("trust"), errors); + validate_pair_object( + object.get("scope"), + "checkpoint source scope", + 64, + 2048, + errors, + ); + validate_engine_binding(object.get("engine_binding"), kind, errors); + } + if !source_ids.windows(2).all(|pair| pair[0] < pair[1]) { + errors.push("checkpoint source ids must be unique and sorted".into()); + } +} + +fn validate_revision( + value: Option<&serde_json::Value>, + source_kind: Option<&str>, + errors: &mut Vec, +) { + let Some(value) = value else { return }; + if value.is_null() { + return; + } + let Some(object) = value.as_object() else { + errors.push("checkpoint source revision must be null or an object".into()); + return; + }; + validate_exact_keys( + object, + &["kind", "value"], + "checkpoint source revision", + errors, + ); + if bounded_string(object.get("kind"), 64).is_none() + || bounded_string(object.get("value"), 2048).is_none() + || object.get("kind").and_then(serde_json::Value::as_str) != source_kind + { + errors.push("checkpoint source revision is invalid".into()); + } +} + +fn validate_freshness(value: Option<&serde_json::Value>, errors: &mut Vec) { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push("checkpoint source freshness must be an object".into()); + return; + }; + let keys = if object.contains_key("valid_until") { + &["observed_at", "status", "valid_until"][..] + } else { + &["observed_at", "status"][..] + }; + validate_exact_keys(object, keys, "checkpoint source freshness", errors); + let observed_raw = object + .get("observed_at") + .and_then(serde_json::Value::as_str); + let observed = observed_raw.and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()); + let valid_until_raw = object + .get("valid_until") + .and_then(serde_json::Value::as_str); + let valid_until = + valid_until_raw.and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()); + if observed_raw.is_none_or(|raw| !canonical_utc_timestamp(raw)) + || !object + .get("status") + .is_some_and(|value| matches!(value.as_str(), Some("current" | "stale" | "unknown"))) + || (object.contains_key("valid_until") + && valid_until_raw.is_none_or(|raw| !canonical_utc_timestamp(raw))) + || valid_until + .zip(observed) + .is_some_and(|(valid, observed)| valid < observed) + { + errors.push("checkpoint source freshness is invalid".into()); + } +} + +fn validate_recovery(value: Option<&serde_json::Value>, errors: &mut Vec) { + let Some(value) = value else { return }; + if value.is_null() { + return; + } + let Some(object) = value.as_object() else { + errors.push("checkpoint source recovery must be null or an object".into()); + return; + }; + let keys = if object.contains_key("digest") { + &["kind", "immutable_ref", "digest"][..] + } else { + &["kind", "immutable_ref"][..] + }; + validate_exact_keys(object, keys, "checkpoint source recovery", errors); + if bounded_string(object.get("kind"), 64).is_none() + || !object + .get("immutable_ref") + .and_then(serde_json::Value::as_str) + .is_some_and(|raw| valid_ref(raw, 2048)) + || object + .get("digest") + .is_some_and(|value| !valid_prefixed_digest(value)) + { + errors.push("checkpoint source recovery is invalid".into()); + } +} + +fn validate_trust(value: Option<&serde_json::Value>, errors: &mut Vec) { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push("checkpoint source trust must be an object".into()); + return; + }; + validate_exact_keys( + object, + &["level", "evidence_refs"], + "checkpoint source trust", + errors, + ); + let level = object.get("level").and_then(serde_json::Value::as_str); + let Some(refs) = object + .get("evidence_refs") + .and_then(serde_json::Value::as_array) + else { + errors.push("checkpoint source trust evidence must be an array".into()); + return; + }; + validate_string_array( + refs, + 32, + 512, + true, + "checkpoint source trust evidence", + errors, + ); + if !matches!(level, Some("unverified" | "local" | "verified")) + || (level == Some("verified") + && (refs.is_empty() + || refs.iter().any(|value| { + !value.as_str().is_some_and(|raw| { + raw.len() == 79 + && raw.starts_with("receipt:sha256:") + && raw[15..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + }))) + { + errors.push("checkpoint source trust is invalid".into()); + } +} + +fn validate_pair_object( + value: Option<&serde_json::Value>, + label: &str, + first_cap: usize, + second_cap: usize, + errors: &mut Vec, +) { + let Some(object) = value.and_then(serde_json::Value::as_object) else { + errors.push(format!("{label} must be an object")); + return; + }; + validate_exact_keys(object, &["kind", "value"], label, errors); + if bounded_string(object.get("kind"), first_cap).is_none() + || bounded_string(object.get("value"), second_cap).is_none() + { + errors.push(format!("{label} is invalid")); + } +} + +fn validate_engine_binding( + value: Option<&serde_json::Value>, + source_kind: Option<&str>, + errors: &mut Vec, +) { + let Some(value) = value else { return }; + if value.is_null() { + return; + } + let Some(object) = value.as_object() else { + errors.push("checkpoint engine binding must be null or an object".into()); + return; + }; + if source_kind != Some("filesystem") { + errors.push("checkpoint engine binding requires a filesystem source".into()); + } + let required = ["path", "project_root", "media_type"]; + let allowed = [ + "path", + "project_root", + "media_type", + "source_ref", + "source_digest", + ]; + if object.keys().any(|key| !allowed.contains(&key.as_str())) + || required.iter().any(|key| !object.contains_key(*key)) + || !object + .get("path") + .and_then(serde_json::Value::as_str) + .is_some_and(valid_relative_source_path) + || !object + .get("project_root") + .and_then(serde_json::Value::as_str) + .is_some_and(valid_normalized_project_root) + || bounded_string(object.get("media_type"), 512).is_none() + || object + .get("source_ref") + .is_some_and(|value| !value.as_str().is_some_and(|raw| valid_ref(raw, 512))) + || object + .get("source_digest") + .is_some_and(|value| !valid_prefixed_digest(value)) + { + errors.push("checkpoint engine binding is invalid".into()); + } +} + +fn valid_relative_source_path(raw: &str) -> bool { + valid_text(raw, 4096) + && !is_absolute_path(raw) + && !raw.contains('\\') + && raw + .split('/') + .all(|component| !component.is_empty() && !matches!(component, "." | "..")) +} + +fn valid_normalized_project_root(raw: &str) -> bool { + if !valid_text(raw, 4096) || !is_absolute_path(raw) { + return false; + } + let tail = if let Some(tail) = raw.strip_prefix("\\\\") { + tail + } else if let Some(tail) = raw.strip_prefix('/') { + tail + } else if raw.len() >= 3 + && raw.as_bytes()[1] == b':' + && matches!(raw.as_bytes()[2], b'/' | b'\\') + { + &raw[3..] + } else { + return false; + }; + tail.is_empty() + || tail + .split(['/', '\\']) + .all(|component| !component.is_empty() && !matches!(component, "." | "..")) +} + +fn validate_context_entries( + entries: &[serde_json::Value], + source_ids: &std::collections::HashSet<&str>, + errors: &mut Vec, +) { + const KEYS: &[&str] = &[ + "schema_version", + "entry_id", + "category", + "value", + "source_ids", + "session_id", + "receipt_refs", + "recovery_refs", + ]; + let mut entry_ids = Vec::new(); + for entry in entries { + let Some(object) = entry.as_object() else { + errors.push("checkpoint context entry must be an object".into()); + continue; + }; + validate_exact_keys(object, KEYS, "checkpoint context entry", errors); + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.project-context-entry/v1") + || !object + .get("entry_id") + .and_then(serde_json::Value::as_str) + .is_some_and(|raw| { + uuid::Uuid::parse_str(raw) + .is_ok_and(|parsed| parsed.hyphenated().to_string() == raw) + }) + || !object.get("category").is_some_and(|value| { + matches!( + value.as_str(), + Some( + "facts" + | "decisions" + | "constraints" + | "unresolved_questions" + | "source_refs" + ) + ) + }) + || bounded_string(object.get("value"), 4096).is_none() + || object + .get("session_id") + .is_some_and(|value| !value.is_null() && bounded_string(Some(value), 512).is_none()) + { + errors.push("checkpoint context entry identity/value is invalid".into()); + } + if let Some(entry_id) = object.get("entry_id").and_then(serde_json::Value::as_str) { + entry_ids.push(entry_id); + } + for (field, cap, item_cap, printable) in [ + ("source_ids", 16, 128, false), + ("receipt_refs", 16, 512, true), + ("recovery_refs", 16, 512, true), + ] { + let Some(items) = object.get(field).and_then(serde_json::Value::as_array) else { + errors.push(format!("checkpoint context entry {field} must be an array")); + continue; + }; + validate_string_array( + items, + cap, + item_cap, + printable, + "checkpoint context entry refs", + errors, + ); + if field == "source_ids" + && items + .iter() + .filter_map(serde_json::Value::as_str) + .any(|source_id| !source_ids.contains(source_id)) + { + errors.push("checkpoint context entry references an unknown source".into()); + } + } + } + if !entry_ids.windows(2).all(|pair| pair[0] < pair[1]) { + errors.push("checkpoint context entry ids must be unique and sorted".into()); + } +} + +fn validate_string_array( + values: &[serde_json::Value], + cap: usize, + item_cap: usize, + printable_ascii: bool, + label: &str, + errors: &mut Vec, +) { + if values.len() > cap + || values.iter().any(|value| { + bounded_string(Some(value), item_cap).is_none() + || (printable_ascii + && !value + .as_str() + .is_some_and(|raw| raw.bytes().all(|byte| (0x20..=0x7e).contains(&byte)))) + }) + || !values + .windows(2) + .all(|pair| pair[0].as_str() < pair[1].as_str()) + { + errors.push(format!("{label} is invalid or exceeds its bound")); + } +} + +fn valid_ref(raw: &str, cap: usize) -> bool { + !raw.is_empty() && raw.len() <= cap && raw.bytes().all(|byte| (0x20..=0x7e).contains(&byte)) +} + +fn canonical_utc_timestamp(raw: &str) -> bool { + (raw.len() == 20 || raw.len() == 27) + && raw.ends_with('Z') + && (raw.len() == 20 || raw.as_bytes().get(19) == Some(&b'.')) + && chrono::DateTime::parse_from_rfc3339(raw).is_ok() +} + +fn bounded_string(value: Option<&serde_json::Value>, cap: usize) -> Option<&str> { + value + .and_then(serde_json::Value::as_str) + .filter(|raw| valid_text(raw, cap)) +} + +fn valid_text(raw: &str, cap: usize) -> bool { + !raw.is_empty() && raw.len() <= cap && !raw.chars().any(char::is_control) +} + +fn validate_package_pins( + pins: &[serde_json::Value], + lock_digest: Option<&serde_json::Value>, + errors: &mut Vec, +) { + const KEYS: &[&str] = &[ + "schema_version", + "name", + "version", + "artifact_digest", + "manifest_digest", + "content_hash", + "signature_state", + "signer_public_key", + "trust_state", + "policy_decision", + ]; + let mut identities = Vec::new(); + for pin in pins { + let Some(object) = pin.as_object() else { + errors.push("checkpoint package pin must be an object".into()); + continue; + }; + validate_exact_keys(object, KEYS, "checkpoint package pin", errors); + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + != Some("leanctx.package-pin/v1") + || object + .get("policy_decision") + .and_then(serde_json::Value::as_str) + != Some("admitted") + { + errors.push("checkpoint package pin contract is unsupported".into()); + } + for name in ["artifact_digest", "manifest_digest", "content_hash"] { + if !object.get(name).is_some_and(valid_prefixed_digest) { + errors.push(format!("checkpoint package pin {name} is invalid")); + } + } + let signature = object + .get("signature_state") + .and_then(serde_json::Value::as_str); + let signer = object.get("signer_public_key"); + if !matches!(signature, Some("signed_valid" | "unsigned")) + || (signature == Some("signed_valid") + && !signer.is_some_and(|value| { + value.as_str().is_some_and(|raw| { + raw.len() == 64 + && raw + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) + })) + || (signature == Some("unsigned") && !signer.is_some_and(serde_json::Value::is_null)) + { + errors.push("checkpoint package pin signature identity is invalid".into()); + } + if !object.get("trust_state").is_some_and(|value| { + matches!(value.as_str(), Some("trusted" | "untrusted" | "unknown")) + }) { + errors.push("checkpoint package pin trust state is invalid".into()); + } + let Some(name) = object.get("name").and_then(serde_json::Value::as_str) else { + errors.push("checkpoint package pin name is invalid".into()); + continue; + }; + let Some(version) = object.get("version").and_then(serde_json::Value::as_str) else { + errors.push("checkpoint package pin version is invalid".into()); + continue; + }; + if !valid_text(name, 128) || !valid_text(version, 64) { + errors.push("checkpoint package pin name/version exceeds bounds".into()); + } + identities.push((name, version)); + } + if !identities.windows(2).all(|pair| pair[0] < pair[1]) { + errors.push("checkpoint package pins must be unique and sorted".into()); + } + if pins.is_empty() { + if !lock_digest.is_some_and(serde_json::Value::is_null) { + errors.push("empty checkpoint package pins require a null lock digest".into()); + } + } else { + validate_domain_digest( + "leanctx.package.lock.v1", + &serde_json::Value::Array(pins.to_vec()), + lock_digest, + "checkpoint.package_lock_digest", + errors, + ); + } +} + +fn validate_migration_provenance( + value: Option<&serde_json::Value>, + checkpoint: &serde_json::Value, + errors: &mut Vec, +) { + let Some(value) = value else { return }; + const KEYS: &[&str] = &[ + "origin", + "legacy_snapshot_id", + "legacy_snapshot_digest", + "migration_contract", + "checkpoint_id", + "state_digest", + "limitations", + ]; + let Some(object) = value.as_object() else { + errors.push("migration_provenance must be an object".into()); + return; + }; + validate_exact_keys(object, KEYS, "migration_provenance", errors); + if object.get("origin").and_then(serde_json::Value::as_str) != Some("SnapshotV1") + || object + .get("migration_contract") + .and_then(serde_json::Value::as_str) + != Some("leanctx.snapshot-v1-migration/v1") + { + errors.push("SnapshotV1 migration provenance is unsupported".into()); + } + for name in ["legacy_snapshot_digest", "state_digest"] { + if !object.get(name).is_some_and(valid_prefixed_digest) { + errors.push(format!("migration_provenance.{name} is invalid")); + } + } + if bounded_string(object.get("legacy_snapshot_id"), 512).is_none() + || !object + .get("limitations") + .and_then(serde_json::Value::as_array) + .is_some_and(|items| { + items.len() <= 64 + && items + .iter() + .all(|item| bounded_string(Some(item), 2048).is_some()) + }) + { + errors.push("migration_provenance.limitations exceeds its bound".into()); + } + if object.get("checkpoint_id") != checkpoint.get("checkpoint_id") + || object.get("state_digest") != checkpoint.get("state_digest") + { + errors.push("migration provenance is not bound to the carried checkpoint".into()); + } +} + +fn validate_exact_keys( + object: &serde_json::Map, + expected: &[&str], + label: &str, + errors: &mut Vec, +) { + if object.len() != expected.len() + || object.keys().any(|key| !expected.contains(&key.as_str())) + || expected.iter().any(|key| !object.contains_key(*key)) + { + errors.push(format!("{label} fields do not match the open contract")); + } +} + +fn valid_prefixed_digest(value: &serde_json::Value) -> bool { + value.as_str().is_some_and(|raw| { + raw.len() == 71 + && raw.starts_with("sha256:") + && raw[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn validate_domain_digest( + domain: &str, + value: &serde_json::Value, + claimed: Option<&serde_json::Value>, + label: &str, + errors: &mut Vec, +) { + let Ok(canonical) = serde_json::to_vec(value) else { + errors.push(format!("{label} input is not canonical JSON")); + return; + }; + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + hasher.update(b"\n"); + hasher.update(canonical); + let expected = format!( + "sha256:{}", + crate::core::agent_identity::hex_encode(&hasher.finalize()) + ); + if claimed.and_then(serde_json::Value::as_str) != Some(expected.as_str()) { + errors.push(format!("{label} does not match canonical content")); + } +} + +fn collect_non_portable_paths(value: &serde_json::Value, pointer: &str, out: &mut Vec) { + match value { + serde_json::Value::Object(object) => { + for (key, item) in object { + let child = format!("{pointer}.{key}"); + if matches!(key.as_str(), "path" | "project_root") + && item.as_str().is_some_and(is_absolute_path) + { + out.push(child.clone()); + } + if matches!(key.as_str(), "canonical_id" | "immutable_ref") + && item.as_str().is_some_and(|value| { + value.starts_with("file:///") || is_absolute_path(value) + }) + { + out.push(child.clone()); + } + collect_non_portable_paths(item, &child, out); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_non_portable_paths(item, &format!("{pointer}[{index}]"), out); + } + } + _ => {} + } +} + +fn is_absolute_path(value: &str) -> bool { + value.starts_with('/') + || value.starts_with("\\\\") + || (value.len() > 2 + && value.as_bytes()[1] == b':' + && matches!(value.as_bytes()[2], b'/' | b'\\')) +} + /// Structural + integrity validation of a `kind=skills` payload (GH #727). /// Every blob must decode and match its plaintext hash — a tampered body /// fails verification, so it can never be materialized on disk. @@ -343,10 +1376,19 @@ pub(crate) fn verify_package_text(doc: &str) -> VerifyReport { // Kind ↔ payload coherence (GH #726) — a structural property: the // declared kind must match the payload the document actually carries. - if let Ok(content) = - serde_json::from_value::(value.get("content").cloned().unwrap_or_default()) - && let Err(errs) = validate_kind_coherence(&manifest, &content) - { + let content = match serde_json::from_value::( + value.get("content").cloned().unwrap_or_default(), + ) { + Ok(content) => content, + Err(error) => { + report.structure = CheckOutcome::Fail; + report + .errors + .push(format!("content does not parse: {error}")); + return report; + } + }; + if let Err(errs) = validate_kind_coherence(&manifest, &content) { report.structure = CheckOutcome::Fail; report.errors.extend(errs); return report; @@ -444,187 +1486,4 @@ pub(crate) fn verify_package_file(path: &Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::context_package::content::PackageContent; - use crate::core::context_package::manifest::{ - CompatibilitySpec, PackageIntegrity, PackageLayer, PackageProvenance, PackageStats, - }; - use chrono::Utc; - - fn signed_bundle_doc() -> String { - let content = PackageContent::default(); - // Arbitrary content text: verification hashes the document bytes and - // never re-parses content into a typed struct. - let content_json = r#"{"note":"hello","weight":1.0}"#.to_string(); - let content_hash = sha256_hex(content_json.as_bytes()); - let sha = sha256_hex(format!("vt-pkg:1.0.0:{content_hash}").as_bytes()); - - let mut manifest = PackageManifest { - schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION, - conformance_level: None, - kind: crate::core::context_package::manifest::PackageKind::default(), - name: "vt-pkg".into(), - version: "1.0.0".into(), - description: "verify test".into(), - author: None, - scope: None, - created_at: Utc::now(), - updated_at: None, - layers: vec![PackageLayer::Knowledge], - dependencies: vec![], - tags: vec![], - visibility: None, - integrity: PackageIntegrity { - sha256: sha, - content_hash, - byte_size: content_json.len() as u64, - }, - provenance: PackageProvenance { - tool: "lean-ctx".into(), - tool_version: "0.0.0".into(), - project_hash: None, - source_session_id: None, - }, - compatibility: CompatibilitySpec::default(), - stats: PackageStats::default(), - signature: None, - graph_summary: None, - marketplace: None, - }; - let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); - super::super::signing::sign_package(&mut manifest, &content, &key); - - format!( - "{{\"manifest\":{},\"content\":{}}}", - serde_json::to_string(&manifest).unwrap(), - content_json - ) - } - - #[test] - fn valid_signed_package_passes_all_checks() { - let report = verify_package_text(&signed_bundle_doc()); - assert!(report.valid(), "errors: {:?}", report.errors); - assert_eq!(report.structure, CheckOutcome::Pass); - assert_eq!(report.content_hash, CheckOutcome::Pass); - assert_eq!(report.package_hash, CheckOutcome::Pass); - assert_eq!(report.signature, CheckOutcome::Pass); - } - - #[test] - fn unsigned_package_skips_signature() { - let doc = signed_bundle_doc(); - let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap(); - v["manifest"]["signature"] = serde_json::Value::Null; - let report = verify_package_text(&serde_json::to_string(&v).unwrap()); - assert_eq!(report.signature, CheckOutcome::Skipped); - } - - #[test] - fn tampered_content_fails_content_hash() { - let doc = signed_bundle_doc().replace("\"hello\"", "\"evil\""); - let report = verify_package_text(&doc); - assert_eq!(report.content_hash, CheckOutcome::Fail); - assert!(!report.valid()); - } - - #[test] - fn whitespace_only_changes_do_not_break_hashing() { - // Pretty-printing the document moves bytes around the content member — - // compaction must recover the writer's exact value literals (incl. 1.0). - let doc = signed_bundle_doc() - .replace("\"content\":{", "\"content\": {\n ") - .replace(",\"weight\"", ",\n \"weight\""); - let report = verify_package_text(&doc); - assert!(report.valid(), "errors: {:?}", report.errors); - } - - #[test] - fn corrupted_signature_fails() { - let doc = signed_bundle_doc(); - let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap(); - let sig = v["manifest"]["signature"]["value"].as_str().unwrap(); - let flipped = if let Some(rest) = sig.strip_prefix("0000") { - format!("ffff{rest}") - } else { - format!("0000{}", &sig[4..]) - }; - v["manifest"]["signature"]["value"] = flipped.into(); - let report = verify_package_text(&serde_json::to_string(&v).unwrap()); - assert_eq!(report.signature, CheckOutcome::Fail); - } - - #[test] - fn missing_manifest_fails_structure() { - let report = verify_package_text("{\"content\":{}}"); - assert_eq!(report.structure, CheckOutcome::Fail); - assert!(report.errors[0].contains("manifest")); - } - - // --- kind ↔ payload coherence (GH #726) --- - - const COHERENT_ADDON_TOML: &str = r#" -[addon] -name = "lean-md" -version = "1.2.0" -description = "Markdown skills runtime" - -[mcp] -transport = "stdio" -command = "lean-md" -args = ["serve"] -"#; - - fn kinded_manifest(kind: super::PackageKind, name: &str, version: &str) -> PackageManifest { - PackageManifest { - schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION, - conformance_level: None, - kind, - name: name.into(), - version: version.into(), - description: "coherence test".into(), - author: None, - scope: None, - created_at: Utc::now(), - updated_at: None, - layers: vec![], - dependencies: vec![], - tags: vec![], - visibility: None, - integrity: PackageIntegrity { - sha256: "a".repeat(64), - content_hash: "b".repeat(64), - byte_size: 1, - }, - provenance: PackageProvenance { - tool: "lean-ctx".into(), - tool_version: "0.0.0".into(), - project_hash: None, - source_session_id: None, - }, - compatibility: CompatibilitySpec::default(), - stats: PackageStats::default(), - signature: None, - graph_summary: None, - marketplace: None, - } - } - - fn addon_content(toml: &str) -> PackageContent { - PackageContent { - addon: Some(crate::core::context_package::content::AddonContent { - manifest_toml: toml.to_string(), - }), - ..PackageContent::default() - } - } - - #[test] - fn context_pack_with_addon_payload_fails() { - let manifest = kinded_manifest(super::PackageKind::Context, "plain-pack", "1.0.0"); - let errs = validate_kind_coherence(&manifest, &addon_content(COHERENT_ADDON_TOML)) - .expect_err("must fail"); - assert!(errs[0].contains("requires kind=addon"), "{errs:?}"); - } -} +mod tests; diff --git a/rust/src/core/context_package/verify/tests.rs b/rust/src/core/context_package/verify/tests.rs new file mode 100644 index 0000000000..ebd2152516 --- /dev/null +++ b/rust/src/core/context_package/verify/tests.rs @@ -0,0 +1,555 @@ +use super::*; +use crate::core::context_package::content::PackageContent; +use crate::core::context_package::manifest::{ + CompatibilitySpec, PackageIntegrity, PackageLayer, PackageProvenance, PackageStats, +}; +use chrono::Utc; + +fn signed_bundle_doc() -> String { + let content = PackageContent::default(); + // Arbitrary content text: verification hashes the document bytes and + // never re-parses content into a typed struct. + let content_json = r#"{"note":"hello","weight":1.0}"#.to_string(); + let content_hash = sha256_hex(content_json.as_bytes()); + let sha = sha256_hex(format!("vt-pkg:1.0.0:{content_hash}").as_bytes()); + + let mut manifest = PackageManifest { + schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION, + conformance_level: None, + kind: crate::core::context_package::manifest::PackageKind::default(), + name: "vt-pkg".into(), + version: "1.0.0".into(), + description: "verify test".into(), + author: None, + scope: None, + created_at: Utc::now(), + updated_at: None, + layers: vec![PackageLayer::Knowledge], + dependencies: vec![], + tags: vec![], + visibility: None, + integrity: PackageIntegrity { + sha256: sha, + content_hash, + byte_size: content_json.len() as u64, + }, + provenance: PackageProvenance { + tool: "lean-ctx".into(), + tool_version: "0.0.0".into(), + project_hash: None, + source_session_id: None, + }, + compatibility: CompatibilitySpec::default(), + stats: PackageStats::default(), + signature: None, + graph_summary: None, + marketplace: None, + }; + let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]); + super::super::signing::sign_package(&mut manifest, &content, &key); + + format!( + "{{\"manifest\":{},\"content\":{}}}", + serde_json::to_string(&manifest).unwrap(), + content_json + ) +} + +#[test] +fn valid_signed_package_passes_all_checks() { + let report = verify_package_text(&signed_bundle_doc()); + assert!(report.valid(), "errors: {:?}", report.errors); + assert_eq!(report.structure, CheckOutcome::Pass); + assert_eq!(report.content_hash, CheckOutcome::Pass); + assert_eq!(report.package_hash, CheckOutcome::Pass); + assert_eq!(report.signature, CheckOutcome::Pass); +} + +fn test_domain_digest(domain: &str, value: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain.as_bytes()); + hasher.update(b"\n"); + hasher.update(serde_json::to_vec(value).unwrap()); + format!( + "sha256:{}", + crate::core::agent_identity::hex_encode(&hasher.finalize()) + ) +} + +fn signed_checkpoint_bundle() -> serde_json::Value { + use crate::core::context_package::content::{ + CHECKPOINT_PACKAGE_SCHEMA_V1, CheckpointPackageContentV1, + }; + + let workspace_id = "123e4567-e89b-42d3-a456-426614174000"; + let checkpoint_id = "123e4567-e89b-42d3-a456-426614174001"; + let source = serde_json::json!({ + "schema_version": "leanctx.source-anchor/v1", + "source_id": "source-1", + "kind": "filesystem", + "canonical_id": "file://source.txt", + "revision": {"kind": "filesystem", "value": format!("sha256:{}", "1".repeat(64))}, + "freshness": {"observed_at": "2026-08-27T00:00:00Z", "status": "current"}, + "recovery": null, + "trust": {"level": "local", "evidence_refs": []}, + "scope": {"kind": "project", "value": "project"}, + "engine_binding": null + }); + let entry = serde_json::json!({ + "schema_version": "leanctx.project-context-entry/v1", + "entry_id": "123e4567-e89b-42d3-a456-426614174002", + "category": "facts", + "value": "portable fact", + "source_ids": ["source-1"], + "session_id": null, + "receipt_refs": [], + "recovery_refs": [format!("recovery:sha256:{}", "2".repeat(64))] + }); + let policy = serde_json::json!({ + "schema_version": "leanctx.workspace-policy/v1", + "allowed_categories": ["constraints", "decisions", "facts", "source_refs", "unresolved_questions"], + "max_events": 4096, + "max_context_entries": 256, + "max_entry_bytes": 65536, + "max_context_bytes": 1048576, + "max_sources": 128, + "max_sessions": 128, + "allow_external_sources": false + }); + let package_pin = serde_json::json!({ + "schema_version": "leanctx.package-pin/v1", + "name": "dependency", + "version": "1.0.0", + "artifact_digest": format!("sha256:{}", "3".repeat(64)), + "manifest_digest": format!("sha256:{}", "4".repeat(64)), + "content_hash": format!("sha256:{}", "5".repeat(64)), + "signature_state": "signed_valid", + "signer_public_key": "6".repeat(64), + "trust_state": "trusted", + "policy_decision": "admitted" + }); + let package_pins = serde_json::json!([package_pin]); + let lock_digest = test_domain_digest("leanctx.package.lock.v1", &package_pins); + let logical = serde_json::json!({ + "schema_version": "leanctx.workspace.state/v1", + "workspace_id": workspace_id, + "policy": policy, + "sources": [source], + "entries": [entry], + "package_pins": package_pins, + "package_lock_digest": lock_digest + }); + let state_digest = test_domain_digest("leanctx.workspace.state.v1", &logical); + let policy_digest = test_domain_digest("leanctx.workspace.policy.v1", &logical["policy"]); + let project_context_digest = + test_domain_digest("leanctx.project-context.state.v1", &logical["entries"]); + let mut checkpoint = serde_json::json!({ + "schema_version": "leanctx.context-checkpoint/v2", + "checkpoint_id": checkpoint_id, + "workspace_id": workspace_id, + "state_digest": state_digest, + "state_schema_version": "leanctx.workspace.state/v1", + "workspace_state_ref": format!("event:sha256:{}", "5".repeat(64)), + "logical_state": logical, + "source_anchors": logical["sources"], + "recovery_refs": [format!("recovery:sha256:{}", "2".repeat(64))], + "package_pins": logical["package_pins"], + "package_lock_digest": lock_digest, + "policy_digest": policy_digest, + "project_context_digest": project_context_digest, + "lineage": {"kind": "workspace", "workspace_id": workspace_id, "state_id": format!("sha256:{}", "6".repeat(64))}, + "engine_identity": {"interface_version": "1.0.0", "schema_version": 1, "transport_version": 1}, + "sdk_contract": "leanctx-product-sdk-research/p6" + }); + let envelope_digest = test_domain_digest("leanctx.checkpoint.envelope.v2", &checkpoint); + checkpoint["envelope_digest"] = envelope_digest.into(); + let portable = CheckpointPackageContentV1 { + schema_version: CHECKPOINT_PACKAGE_SCHEMA_V1.into(), + migration_provenance: Some(serde_json::json!({ + "origin": "SnapshotV1", + "legacy_snapshot_id": "snapshot-1", + "legacy_snapshot_digest": format!("sha256:{}", "7".repeat(64)), + "migration_contract": "leanctx.snapshot-v1-migration/v1", + "checkpoint_id": checkpoint_id, + "state_digest": state_digest, + "limitations": ["local recovery requires explicit rebinding"] + })), + checkpoint, + non_portable_fields: vec![], + }; + let (mut manifest, content) = + crate::core::context_package::PackageBuilder::new("checkpoint-fixture", "1.0.0") + .description("checkpoint fixture") + .checkpoint(portable) + .build() + .unwrap(); + let content_value = serde_json::to_value(&content).unwrap(); + let content_json = serde_json::to_string(&content_value).unwrap(); + let content_hash = sha256_hex(content_json.as_bytes()); + manifest.integrity.content_hash.clone_from(&content_hash); + manifest.integrity.sha256 = + sha256_hex(format!("{}:{}:{content_hash}", manifest.name, manifest.version).as_bytes()); + manifest.integrity.byte_size = content_json.len() as u64; + let key = ed25519_dalek::SigningKey::from_bytes(&[11u8; 32]); + super::super::signing::sign_package(&mut manifest, &content, &key); + serde_json::json!({"manifest": manifest, "content": content_value}) +} + +fn rehash_package_without_resigning(bundle: &mut serde_json::Value) { + let content = serde_json::to_string(&bundle["content"]).unwrap(); + let content_hash = sha256_hex(content.as_bytes()); + let name = bundle["manifest"]["name"].as_str().unwrap(); + let version = bundle["manifest"]["version"].as_str().unwrap(); + let package_hash = sha256_hex(format!("{name}:{version}:{content_hash}").as_bytes()); + bundle["manifest"]["integrity"]["content_hash"] = content_hash.into(); + bundle["manifest"]["integrity"]["sha256"] = package_hash.into(); + bundle["manifest"]["integrity"]["byte_size"] = content.len().into(); +} + +#[test] +fn checkpoint_package_is_additive_signed_v2_and_generic_load_rejects() { + let bundle = signed_checkpoint_bundle(); + let report = verify_package_text(&serde_json::to_string(&bundle).unwrap()); + assert!(report.valid(), "errors: {:?}", report.errors); + assert_eq!(report.signature, CheckOutcome::Pass); + let manifest: PackageManifest = serde_json::from_value(bundle["manifest"].clone()).unwrap(); + let content: PackageContent = serde_json::from_value(bundle["content"].clone()).unwrap(); + assert_eq!(manifest.schema_version, 2); + assert_eq!(manifest.kind, PackageKind::Context); + assert!(manifest.has_layer(PackageLayer::Checkpoint)); + assert!(super::super::loader::load_package(&manifest, &content, ".").is_err()); +} + +#[test] +fn pre_extension_reader_fails_closed_on_checkpoint_layer() { + #[derive(serde::Deserialize)] + #[serde(rename_all = "snake_case")] + enum LegacyLayer { + Knowledge, + Graph, + Session, + Patterns, + Gotchas, + } + #[derive(serde::Deserialize)] + struct LegacyManifest { + layers: Vec, + } + let bundle = signed_checkpoint_bundle(); + let old = serde_json::from_value::(bundle["manifest"].clone()); + assert!(old.is_err()); +} + +#[test] +fn every_checkpoint_critical_field_is_signature_bound() { + let mutations: &[(&str, fn(&mut serde_json::Value))] = &[ + ("checkpoint_id", |value| { + value["content"]["checkpoint"]["checkpoint"]["checkpoint_id"] = + "123e4567-e89b-42d3-a456-426614174099".into(); + }), + ("state_digest", |value| { + value["content"]["checkpoint"]["checkpoint"]["state_digest"] = + format!("sha256:{}", "0".repeat(64)).into(); + }), + ("workspace_id", |value| { + value["content"]["checkpoint"]["checkpoint"]["workspace_id"] = + "123e4567-e89b-42d3-a456-426614174098".into(); + }), + ("source_revision", |value| { + value["content"]["checkpoint"]["checkpoint"]["source_anchors"][0]["revision"]["value"] = + format!("sha256:{}", "8".repeat(64)).into(); + }), + ("recovery_ref", |value| { + value["content"]["checkpoint"]["checkpoint"]["recovery_refs"][0] = + format!("recovery:sha256:{}", "8".repeat(64)).into(); + }), + ("project_context", |value| { + value["content"]["checkpoint"]["checkpoint"]["logical_state"]["entries"][0]["value"] = + "tampered".into(); + }), + ("policy_digest", |value| { + value["content"]["checkpoint"]["checkpoint"]["policy_digest"] = + format!("sha256:{}", "8".repeat(64)).into(); + }), + ("package_pin", |value| { + value["content"]["checkpoint"]["checkpoint"]["package_pins"][0]["artifact_digest"] = + format!("sha256:{}", "8".repeat(64)).into(); + }), + ("migration", |value| { + value["content"]["checkpoint"]["migration_provenance"]["legacy_snapshot_id"] = + "tampered".into(); + }), + ("logical_state", |value| { + value["content"]["checkpoint"]["checkpoint"]["logical_state"]["workspace_id"] = + "123e4567-e89b-42d3-a456-426614174097".into(); + }), + ]; + for (name, mutate) in mutations { + let mut bundle = signed_checkpoint_bundle(); + mutate(&mut bundle); + rehash_package_without_resigning(&mut bundle); + let report = verify_package_text(&serde_json::to_string(&bundle).unwrap()); + assert!(!report.valid(), "{name} tamper unexpectedly passed"); + } +} + +#[test] +fn checkpoint_layer_content_secret_and_path_rules_fail_closed() { + let bundle = signed_checkpoint_bundle(); + let mut manifest: PackageManifest = serde_json::from_value(bundle["manifest"].clone()).unwrap(); + let mut content: PackageContent = serde_json::from_value(bundle["content"].clone()).unwrap(); + + manifest.layers.clear(); + let errors = validate_kind_coherence(&manifest, &content).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.contains("present together")) + ); + + manifest.layers.push(PackageLayer::Checkpoint); + content.checkpoint.as_mut().unwrap().checkpoint["logical_state"]["entries"][0]["value"] = + ("AK".to_owned() + "IAABCDEFGHIJKLMNOP").into(); + content.checkpoint.as_mut().unwrap().checkpoint["source_anchors"][0]["engine_binding"] = serde_json::json!({ + "path": "source.txt", + "project_root": "/machine/one/project", + "media_type": "text/plain", + "source_ref": null, + "source_digest": null + }); + let errors = validate_kind_coherence(&manifest, &content).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.contains("credential-shaped")) + ); + assert!( + errors + .iter() + .any(|error| error.contains("non_portable_fields")) + ); +} + +#[test] +fn checkpoint_nested_contract_rejects_invalid_structure() { + let bundle = signed_checkpoint_bundle(); + let manifest: PackageManifest = serde_json::from_value(bundle["manifest"].clone()).unwrap(); + let mut content: PackageContent = serde_json::from_value(bundle["content"].clone()).unwrap(); + let checkpoint = &mut content.checkpoint.as_mut().unwrap().checkpoint; + checkpoint["logical_state"]["sources"][0]["trust"]["level"] = "verified".into(); + checkpoint["logical_state"]["sources"][0]["trust"]["evidence_refs"] = + serde_json::json!(["forged"]); + checkpoint["logical_state"]["entries"][0]["source_ids"] = serde_json::json!(["missing-source"]); + checkpoint["logical_state"]["policy"]["allow_external_sources"] = "false".into(); + checkpoint["lineage"]["state_id"] = "not-a-digest".into(); + checkpoint["source_anchors"] = checkpoint["logical_state"]["sources"].clone(); + + let errors = validate_kind_coherence(&manifest, &content).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.contains("source trust is invalid")) + ); + assert!(errors.iter().any(|error| error.contains("unknown source"))); + assert!( + errors + .iter() + .any(|error| error.contains("workspace policy is invalid")) + ); + assert!( + errors + .iter() + .any(|error| error.contains("lineage is invalid")) + ); +} + +#[test] +fn checkpoint_nested_contract_matches_sdk_bounds_and_ordering() { + let bundle = signed_checkpoint_bundle(); + let manifest: PackageManifest = serde_json::from_value(bundle["manifest"].clone()).unwrap(); + let mut content: PackageContent = serde_json::from_value(bundle["content"].clone()).unwrap(); + let portable = content.checkpoint.as_mut().unwrap(); + let checkpoint = &mut portable.checkpoint; + let mut first = checkpoint["logical_state"]["sources"][0].clone(); + first["source_id"] = "source-b".into(); + first["freshness"]["observed_at"] = "2026-08-27T00:00:00+00:00".into(); + first["trust"]["evidence_refs"] = serde_json::json!(["z", "a"]); + first["recovery"] = serde_json::json!({ + "kind": "archive", + "immutable_ref": "/machine/recovery" + }); + first["engine_binding"]["source_ref"] = "bad\nref".into(); + first["engine_binding"]["source_digest"] = format!("sha256:{}", "A".repeat(64)).into(); + let mut second = first.clone(); + second["source_id"] = "source-a".into(); + checkpoint["logical_state"]["sources"] = serde_json::json!([first, second]); + checkpoint["source_anchors"] = checkpoint["logical_state"]["sources"].clone(); + checkpoint["logical_state"]["entries"][0]["source_ids"] = serde_json::json!(["source-b"]); + checkpoint["logical_state"]["entries"][0]["value"] = "x".repeat(4097).into(); + checkpoint["logical_state"]["package_pins"][0]["name"] = "bad\nname".into(); + checkpoint["package_pins"] = checkpoint["logical_state"]["package_pins"].clone(); + + let errors = validate_kind_coherence(&manifest, &content).unwrap_err(); + for expected in [ + "source ids must be unique and sorted", + "source freshness is invalid", + "source trust evidence is invalid", + "engine binding is invalid", + "context entry identity/value is invalid", + "name/version exceeds bounds", + "non_portable_fields", + ] { + assert!( + errors.iter().any(|error| error.contains(expected)), + "missing error for {expected}: {errors:?}" + ); + } +} + +#[test] +fn checkpoint_engine_binding_requires_sdk_canonical_projection() { + let canonical = serde_json::json!({ + "path": "dir/source.txt", + "project_root": "/project", + "media_type": "text/plain" + }); + let mut errors = Vec::new(); + validate_engine_binding(Some(&canonical), Some("filesystem"), &mut errors); + assert!(errors.is_empty(), "canonical binding rejected: {errors:?}"); + + let invalid = serde_json::json!({ + "path": "../escape.txt", + "project_root": "/project/../other", + "media_type": "text/plain", + "source_ref": null, + "source_digest": null + }); + validate_engine_binding(Some(&invalid), Some("filesystem"), &mut errors); + assert!( + errors + .iter() + .any(|error| error.contains("engine binding is invalid")) + ); +} + +#[test] +fn default_package_content_serialization_is_byte_compatible() { + let json = serde_json::to_string(&PackageContent::default()).unwrap(); + assert!(!json.contains("checkpoint")); +} + +#[test] +fn unsigned_package_skips_signature() { + let doc = signed_bundle_doc(); + let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap(); + v["manifest"]["signature"] = serde_json::Value::Null; + let report = verify_package_text(&serde_json::to_string(&v).unwrap()); + assert_eq!(report.signature, CheckOutcome::Skipped); +} + +#[test] +fn tampered_content_fails_content_hash() { + let doc = signed_bundle_doc().replace("\"hello\"", "\"evil\""); + let report = verify_package_text(&doc); + assert_eq!(report.content_hash, CheckOutcome::Fail); + assert!(!report.valid()); +} + +#[test] +fn whitespace_only_changes_do_not_break_hashing() { + // Pretty-printing the document moves bytes around the content member — + // compaction must recover the writer's exact value literals (incl. 1.0). + let doc = signed_bundle_doc() + .replace("\"content\":{", "\"content\": {\n ") + .replace(",\"weight\"", ",\n \"weight\""); + let report = verify_package_text(&doc); + assert!(report.valid(), "errors: {:?}", report.errors); +} + +#[test] +fn corrupted_signature_fails() { + let doc = signed_bundle_doc(); + let mut v: serde_json::Value = serde_json::from_str(&doc).unwrap(); + let sig = v["manifest"]["signature"]["value"].as_str().unwrap(); + let flipped = if let Some(rest) = sig.strip_prefix("0000") { + format!("ffff{rest}") + } else { + format!("0000{}", &sig[4..]) + }; + v["manifest"]["signature"]["value"] = flipped.into(); + let report = verify_package_text(&serde_json::to_string(&v).unwrap()); + assert_eq!(report.signature, CheckOutcome::Fail); +} + +#[test] +fn missing_manifest_fails_structure() { + let report = verify_package_text("{\"content\":{}}"); + assert_eq!(report.structure, CheckOutcome::Fail); + assert!(report.errors[0].contains("manifest")); +} + +// --- kind ↔ payload coherence (GH #726) --- + +const COHERENT_ADDON_TOML: &str = r#" +[addon] +name = "sample-addon" +version = "1.2.0" +description = "Markdown skills runtime" + +[mcp] +transport = "stdio" +command = "sample-addon" +args = ["serve"] +"#; + +fn kinded_manifest(kind: super::PackageKind, name: &str, version: &str) -> PackageManifest { + PackageManifest { + schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION, + conformance_level: None, + kind, + name: name.into(), + version: version.into(), + description: "coherence test".into(), + author: None, + scope: None, + created_at: Utc::now(), + updated_at: None, + layers: vec![], + dependencies: vec![], + tags: vec![], + visibility: None, + integrity: PackageIntegrity { + sha256: "a".repeat(64), + content_hash: "b".repeat(64), + byte_size: 1, + }, + provenance: PackageProvenance { + tool: "lean-ctx".into(), + tool_version: "0.0.0".into(), + project_hash: None, + source_session_id: None, + }, + compatibility: CompatibilitySpec::default(), + stats: PackageStats::default(), + signature: None, + graph_summary: None, + marketplace: None, + } +} + +fn addon_content(toml: &str) -> PackageContent { + PackageContent { + addon: Some(crate::core::context_package::content::AddonContent { + manifest_toml: toml.to_string(), + }), + ..PackageContent::default() + } +} + +#[test] +fn context_pack_with_addon_payload_fails() { + let manifest = kinded_manifest(super::PackageKind::Context, "plain-pack", "1.0.0"); + let errs = validate_kind_coherence(&manifest, &addon_content(COHERENT_ADDON_TOML)) + .expect_err("must fail"); + assert!(errs[0].contains("requires kind=addon"), "{errs:?}"); +} diff --git a/rust/src/core/context_package/verify/text.rs b/rust/src/core/context_package/verify/text.rs new file mode 100644 index 0000000000..760f1a5fe9 --- /dev/null +++ b/rust/src/core/context_package/verify/text.rs @@ -0,0 +1,30 @@ +/// Strip insignificant whitespace outside string literals (spec §8). +pub(crate) fn compact_json_text(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + let mut in_string = false; + while let Some(ch) = chars.next() { + if in_string { + out.push(ch); + match ch { + '\\' => { + if let Some(esc) = chars.next() { + out.push(esc); + } + } + '"' => in_string = false, + _ => {} + } + } else { + match ch { + '"' => { + in_string = true; + out.push(ch); + } + ' ' | '\t' | '\n' | '\r' => {} + _ => out.push(ch), + } + } + } + out +} diff --git a/tests/delivery/test_check_release_tag.py b/tests/delivery/test_check_release_tag.py index 5976037fc8..a937f64dab 100644 --- a/tests/delivery/test_check_release_tag.py +++ b/tests/delivery/test_check_release_tag.py @@ -37,7 +37,7 @@ def assert_tag_rejected(self, tag): GATE.verify_tag(tag, root) def test_current_repository_tag_matches_all_release_versions(self): - self.assertEqual(GATE.verify_tag("v3.9.20", ROOT), "3.9.20") + self.assertEqual(GATE.verify_tag("v3.10.0", ROOT), "3.10.0") def test_accepts_strict_prerelease_when_every_manifest_matches(self): temporary, root = self.fixture_root("3.9.11-rc.1")