From c19ccec571a73bca1cff2b5aef84ea6b4ed49fff Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:38:02 +0200 Subject: [PATCH 1/7] feat(p6): add fail-closed ctxpkg checkpoint layer --- docs/specs/context-package-v2.md | 18 + docs/specs/context-package-v2.schema.json | 81 +- rust/src/cli/pack_cmd/checkpoint.rs | 166 +++ rust/src/cli/pack_cmd/mod.rs | 7 + rust/src/core/context_package/builder.rs | 15 +- rust/src/core/context_package/content.rs | 28 + rust/src/core/context_package/loader.rs | 8 +- rust/src/core/context_package/manifest.rs | 8 + rust/src/core/context_package/registry.rs | 63 +- rust/src/core/context_package/verify.rs | 1315 ++++++++++++++++++++- 10 files changed, 1696 insertions(+), 13 deletions(-) create mode 100644 rust/src/cli/pack_cmd/checkpoint.rs 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/rust/src/cli/pack_cmd/checkpoint.rs b/rust/src/cli/pack_cmd/checkpoint.rs new file mode 100644 index 0000000000..3a587bd5f8 --- /dev/null +++ b/rust/src/cli/pack_cmd/checkpoint.rs @@ -0,0 +1,166 @@ +use std::path::{Path, PathBuf}; + +use crate::core::context_package::PackageLayer; +use crate::core::context_package::content::CheckpointPackageContentV1; +use sha2::{Digest, Sha256}; + +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]", + ); + }; + + 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 ")); + 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") + ); +} + +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..f1af8a7a20 100644 --- a/rust/src/core/context_package/content.rs +++ b/rust/src/core/context_package/content.rs @@ -29,6 +29,31 @@ 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, + #[serde(default)] + pub non_portable_fields: Vec, } /// Distribution view of an addon (unified distribution, GH #726): the @@ -260,6 +285,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..899f934f75 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -8,8 +8,12 @@ use sha2::{Digest, Sha256}; use std::path::Path; -use super::content::PackageContent; -use super::manifest::{PackageKind, PackageManifest}; +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}; /// Strip insignificant whitespace outside string literals (spec §8). pub(crate) fn compact_json_text(text: &str) -> String { @@ -174,6 +178,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 +199,974 @@ 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() > MAX_CHECKPOINT_REFS + || declared + .iter() + .any(|item| item.is_empty() || item.len() > 2048 || 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, + 2048, + "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); + } + source_ids.sort_unstable(); + 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 = object + .get("observed_at") + .and_then(serde_json::Value::as_str) + .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()); + let valid_until = object + .get("valid_until") + .filter(|value| !value.is_null()) + .and_then(|value| value.as_str()) + .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()); + if observed.is_none() + || !object + .get("status") + .is_some_and(|value| matches!(value.as_str(), Some("current" | "stale" | "unknown"))) + || (object + .get("valid_until") + .is_some_and(|value| !value.is_null()) + && valid_until.is_none()) + || 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() + || bounded_string(object.get("immutable_ref"), 2048).is_none() + || 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, 2048, "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)) + || bounded_string(object.get("path"), 4096).is_none() + || bounded_string(object.get("project_root"), 4096).is_none() + || bounded_string(object.get("media_type"), 256).is_none() + || ["source_ref", "source_digest"].iter().any(|key| { + object.get(*key).is_some_and(|value| { + !value.is_null() && bounded_string(Some(value), 2048).is_none() + }) + }) + { + errors.push("checkpoint engine binding is invalid".into()); + } +} + +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"), 65_536).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) in [ + ("source_ids", 64), + ("receipt_refs", 64), + ("recovery_refs", 64), + ] { + 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, 2048, "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()); + } + } + } + entry_ids.sort_unstable(); + if entry_ids.windows(2).any(|pair| pair[0] == pair[1]) { + errors.push("checkpoint context entry ids must be unique".into()); + } +} + +fn validate_string_array( + values: &[serde_json::Value], + cap: usize, + item_cap: usize, + label: &str, + errors: &mut Vec, +) { + if values.len() > cap + || values + .iter() + .any(|value| bounded_string(Some(value), item_cap).is_none()) + { + errors.push(format!("{label} is invalid or exceeds its bound")); + } +} + +fn bounded_string(value: Option<&serde_json::Value>, cap: usize) -> Option<&str> { + value + .and_then(serde_json::Value::as_str) + .filter(|raw| !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 name.is_empty() || name.len() > 128 || version.is_empty() || version.len() > 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:///")) + { + 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 +1329,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; @@ -512,6 +1507,314 @@ mod tests { 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 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(); From 622839792ad770b53367f2f1da562c383f055ed5 Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:51:19 +0200 Subject: [PATCH 2/7] fix(p6): align checkpoint carrier with SDK bounds --- rust/src/cli/pack_cmd/checkpoint.rs | 23 ++++ rust/src/core/context_package/content.rs | 1 - rust/src/core/context_package/verify.rs | 162 +++++++++++++++++------ 3 files changed, 145 insertions(+), 41 deletions(-) diff --git a/rust/src/cli/pack_cmd/checkpoint.rs b/rust/src/cli/pack_cmd/checkpoint.rs index 3a587bd5f8..7470ee7c27 100644 --- a/rust/src/cli/pack_cmd/checkpoint.rs +++ b/rust/src/cli/pack_cmd/checkpoint.rs @@ -4,6 +4,9 @@ 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"); @@ -16,6 +19,11 @@ pub(super) fn cmd_pack_checkpoint_seal(args: &[String]) { ); }; + 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) @@ -62,6 +70,11 @@ pub(super) fn cmd_pack_checkpoint_inspect(args: &[String]) { .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}"))); @@ -91,6 +104,16 @@ pub(super) fn cmd_pack_checkpoint_inspect(args: &[String]) { ); } +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, diff --git a/rust/src/core/context_package/content.rs b/rust/src/core/context_package/content.rs index f1af8a7a20..f3571927c1 100644 --- a/rust/src/core/context_package/content.rs +++ b/rust/src/core/context_package/content.rs @@ -52,7 +52,6 @@ pub(crate) struct CheckpointPackageContentV1 { pub checkpoint: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] pub migration_provenance: Option, - #[serde(default)] pub non_portable_fields: Vec, } diff --git a/rust/src/core/context_package/verify.rs b/rust/src/core/context_package/verify.rs index 899f934f75..9fa4280951 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -232,10 +232,10 @@ fn validate_checkpoint_content(portable: &CheckpointPackageContentV1, errors: &m declared.sort(); declared.dedup(); if declared != portable.non_portable_fields - || declared.len() > MAX_CHECKPOINT_REFS + || declared.len() > 256 || declared .iter() - .any(|item| item.is_empty() || item.len() > 2048 || item.chars().any(char::is_control)) + .any(|item| item.is_empty() || item.len() > 1024 || item.chars().any(char::is_control)) || declared != absolute_paths { errors.push( @@ -428,7 +428,8 @@ fn validate_checkpoint_object(value: &serde_json::Value, errors: &mut Vec, errors: &mut Vec, errors: &mut Vec }; validate_exact_keys(object, keys, "checkpoint source recovery", errors); if bounded_string(object.get("kind"), 64).is_none() - || bounded_string(object.get("immutable_ref"), 2048).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)) @@ -756,7 +757,14 @@ fn validate_trust(value: Option<&serde_json::Value>, errors: &mut Vec) { errors.push("checkpoint source trust evidence must be an array".into()); return; }; - validate_string_array(refs, 32, 2048, "checkpoint source trust evidence", errors); + 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() @@ -821,12 +829,13 @@ fn validate_engine_binding( || required.iter().any(|key| !object.contains_key(*key)) || bounded_string(object.get("path"), 4096).is_none() || bounded_string(object.get("project_root"), 4096).is_none() - || bounded_string(object.get("media_type"), 256).is_none() - || ["source_ref", "source_digest"].iter().any(|key| { - object.get(*key).is_some_and(|value| { - !value.is_null() && bounded_string(Some(value), 2048).is_none() - }) + || bounded_string(object.get("media_type"), 512).is_none() + || object.get("source_ref").is_some_and(|value| { + !value.is_null() && !value.as_str().is_some_and(|raw| valid_ref(raw, 512)) }) + || object + .get("source_digest") + .is_some_and(|value| !value.is_null() && !valid_prefixed_digest(value)) { errors.push("checkpoint engine binding is invalid".into()); } @@ -877,7 +886,7 @@ fn validate_context_entries( ) ) }) - || bounded_string(object.get("value"), 65_536).is_none() + || 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()) @@ -887,16 +896,23 @@ fn validate_context_entries( if let Some(entry_id) = object.get("entry_id").and_then(serde_json::Value::as_str) { entry_ids.push(entry_id); } - for (field, cap) in [ - ("source_ids", 64), - ("receipt_refs", 64), - ("recovery_refs", 64), + 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, 2048, "checkpoint context entry refs", errors); + validate_string_array( + items, + cap, + item_cap, + printable, + "checkpoint context entry refs", + errors, + ); if field == "source_ids" && items .iter() @@ -907,9 +923,8 @@ fn validate_context_entries( } } } - entry_ids.sort_unstable(); - if entry_ids.windows(2).any(|pair| pair[0] == pair[1]) { - errors.push("checkpoint context entry ids must be unique".into()); + if !entry_ids.windows(2).all(|pair| pair[0] < pair[1]) { + errors.push("checkpoint context entry ids must be unique and sorted".into()); } } @@ -917,22 +932,45 @@ 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()) + || 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| !raw.is_empty() && raw.len() <= cap && !raw.chars().any(char::is_control)) + .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( @@ -1006,7 +1044,7 @@ fn validate_package_pins( errors.push("checkpoint package pin version is invalid".into()); continue; }; - if name.is_empty() || name.len() > 128 || version.is_empty() || version.len() > 64 { + if !valid_text(name, 128) || !valid_text(version, 64) { errors.push("checkpoint package pin name/version exceeds bounds".into()); } identities.push((name, version)); @@ -1141,9 +1179,9 @@ fn collect_non_portable_paths(value: &serde_json::Value, pointer: &str, out: &mu out.push(child.clone()); } if matches!(key.as_str(), "canonical_id" | "immutable_ref") - && item - .as_str() - .is_some_and(|value| value.starts_with("file:///")) + && item.as_str().is_some_and(|value| { + value.starts_with("file:///") || is_absolute_path(value) + }) { out.push(child.clone()); } @@ -1809,6 +1847,50 @@ mod tests { ); } + #[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 default_package_content_serialization_is_byte_compatible() { let json = serde_json::to_string(&PackageContent::default()).unwrap(); From 6fb2b04813635e6a324a48b44587036a5069d647 Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:57:09 +0200 Subject: [PATCH 3/7] fix(p6): enforce canonical checkpoint engine bindings --- rust/src/core/context_package/verify.rs | 75 +++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 6 deletions(-) diff --git a/rust/src/core/context_package/verify.rs b/rust/src/core/context_package/verify.rs index 9fa4280951..9c00232590 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -827,20 +827,57 @@ fn validate_engine_binding( ]; if object.keys().any(|key| !allowed.contains(&key.as_str())) || required.iter().any(|key| !object.contains_key(*key)) - || bounded_string(object.get("path"), 4096).is_none() - || bounded_string(object.get("project_root"), 4096).is_none() + || !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.is_null() && !value.as_str().is_some_and(|raw| valid_ref(raw, 512)) - }) + || 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| !value.is_null() && !valid_prefixed_digest(value)) + .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>, @@ -1891,6 +1928,32 @@ mod tests { } } + #[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(); From a4d8734591cb924731b28a55aba001fee81281c1 Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:32:38 +0200 Subject: [PATCH 4/7] chore(release): v3.10.0 --- CHANGELOG.md | 7 ++++++- docs/contracts/README.md | 2 +- packages/lean-ctx-bin/package.json | 2 +- packages/pi-lean-ctx/package.json | 2 +- rust/Cargo.lock | 2 +- rust/Cargo.toml | 2 +- tests/delivery/test_check_release_tag.py | 2 +- 7 files changed, 12 insertions(+), 7 deletions(-) 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/contracts/README.md b/docs/contracts/README.md index 70f2cc97ba..0b4e2d87a3 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -9,7 +9,7 @@ > them. See [`docs/internal/README.md`](../internal/README.md). > Single entry point for all OCLA wire contracts, schemas, and specifications. -> Version: aligned with lean-ctx v3.9.20 +> Version: aligned with lean-ctx v3.10.0 This portal is the navigable index for the contracts in this directory. The Rust OCLA types, JSON Schema, and Protobuf definitions remain the authoritative 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/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") From 7e6d8c3a3a5528f6a259c4d58ec55c682315709d Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:47:22 +0200 Subject: [PATCH 5/7] refactor(ctxpkg): split verifier for LOC gate --- rust/src/core/context_package/verify.rs | 595 +----------------- rust/src/core/context_package/verify/tests.rs | 555 ++++++++++++++++ rust/src/core/context_package/verify/text.rs | 30 + 3 files changed, 588 insertions(+), 592 deletions(-) create mode 100644 rust/src/core/context_package/verify/tests.rs create mode 100644 rust/src/core/context_package/verify/text.rs diff --git a/rust/src/core/context_package/verify.rs b/rust/src/core/context_package/verify.rs index 9c00232590..344e3baef4 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -15,36 +15,8 @@ use super::content::{ }; use super::manifest::{PackageKind, PackageLayer, 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 -} +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). @@ -1514,565 +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); - } - - 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 = "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..453f650d32 --- /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 = "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:?}"); +} 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 +} From ab17507667816cf325992022301f197838c83176 Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:00:41 +0200 Subject: [PATCH 6/7] fix(release): preserve signed OCLA contract pack --- docs/contracts/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 0b4e2d87a3..70f2cc97ba 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -9,7 +9,7 @@ > them. See [`docs/internal/README.md`](../internal/README.md). > Single entry point for all OCLA wire contracts, schemas, and specifications. -> Version: aligned with lean-ctx v3.10.0 +> Version: aligned with lean-ctx v3.9.20 This portal is the navigable index for the contracts in this directory. The Rust OCLA types, JSON Schema, and Protobuf definitions remain the authoritative From f9aae0c59eb08473bc24f800dd3ec476966e69a2 Mon Sep 17 00:00:00 2001 From: Yves Gugger <7590809+yvgude@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:22:38 +0200 Subject: [PATCH 7/7] test(ctxpkg): keep addon fixture reverse-cut neutral --- rust/src/core/context_package/verify/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/src/core/context_package/verify/tests.rs b/rust/src/core/context_package/verify/tests.rs index 453f650d32..ebd2152516 100644 --- a/rust/src/core/context_package/verify/tests.rs +++ b/rust/src/core/context_package/verify/tests.rs @@ -492,13 +492,13 @@ fn missing_manifest_fails_structure() { const COHERENT_ADDON_TOML: &str = r#" [addon] -name = "lean-md" +name = "sample-addon" version = "1.2.0" description = "Markdown skills runtime" [mcp] transport = "stdio" -command = "lean-md" +command = "sample-addon" args = ["serve"] "#;