Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/specs/context-package-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 79 additions & 2 deletions docs/specs/context-package-v2.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion packages/lean-ctx-bin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/pi-lean-ctx/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
189 changes: 189 additions & 0 deletions rust/src/cli/pack_cmd/checkpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
use std::path::{Path, PathBuf};

use crate::core::context_package::PackageLayer;
use crate::core::context_package::content::CheckpointPackageContentV1;
use sha2::{Digest, Sha256};

const MAX_CHECKPOINT_INPUT_BYTES: u64 = 8 * 1024 * 1024;
const MAX_CHECKPOINT_PACKAGE_BYTES: u64 = 16 * 1024 * 1024;

pub(super) fn cmd_pack_checkpoint_seal(args: &[String]) {
let input = flag(args, "--checkpoint");
let output = flag(args, "--output");
let name = flag(args, "--name");
let version = flag(args, "--version").unwrap_or_else(|| "1.0.0".into());
let unsigned = args.iter().any(|arg| arg == "--unsigned");
let (Some(input), Some(output), Some(name)) = (input, output, name) else {
fail(
"Usage: lean-ctx pack checkpoint-seal --checkpoint=<payload.json> --output=<file.ctxpkg> --name=<name> [--version=<v>] [--unsigned]",
);
};

require_bounded_regular_file(
Path::new(&input),
MAX_CHECKPOINT_INPUT_BYTES,
"checkpoint payload",
);
let raw = std::fs::read_to_string(&input)
.unwrap_or_else(|error| fail(&format!("read checkpoint payload: {error}")));
let checkpoint: CheckpointPackageContentV1 = serde_json::from_str(&raw)
.unwrap_or_else(|error| fail(&format!("parse checkpoint payload: {error}")));
let (manifest, content) = crate::core::context_package::PackageBuilder::new(&name, &version)
.description("Portable ContextCheckpointV2")
.checkpoint(checkpoint)
.build()
.unwrap_or_else(|error| fail(&format!("build checkpoint package: {error}")));

let signing_key = if unsigned {
None
} else {
Some(
crate::core::context_package::keys::load_or_create()
.unwrap_or_else(|error| fail(&format!("signing key: {error}")))
.0,
)
};
let manifest = crate::core::context_package::registry::write_checkpoint_bundle(
manifest,
content,
Path::new(&output),
signing_key.as_ref(),
)
.unwrap_or_else(|error| fail(&format!("seal checkpoint package: {error}")));
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"schema_version": "leanctx.ctxpkg-checkpoint-seal/v1",
"path": PathBuf::from(output),
"name": manifest.name,
"version": manifest.version,
"package_digest": format!("sha256:{}", manifest.integrity.sha256),
"content_hash": format!("sha256:{}", manifest.integrity.content_hash),
"signature_state": if manifest.signature.is_some() { "signed_valid" } else { "unsigned" },
}))
.expect("seal result serializes")
);
}

pub(super) fn cmd_pack_checkpoint_inspect(args: &[String]) {
let file = args
.iter()
.find(|arg| !arg.starts_with("--") && arg.as_str() != "checkpoint-inspect")
.unwrap_or_else(|| fail("Usage: lean-ctx pack checkpoint-inspect <file.ctxpkg>"));
require_bounded_regular_file(
Path::new(file),
MAX_CHECKPOINT_PACKAGE_BYTES,
"checkpoint package",
);
let (manifest, checkpoint) =
crate::core::context_package::registry::read_checkpoint_bundle(Path::new(file))
.unwrap_or_else(|error| fail(&format!("inspect checkpoint package: {error}")));
let signature_state = if manifest.signature.is_some() {
"signed_valid"
} else {
"unsigned"
};
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"schema_version": "leanctx.ctxpkg-checkpoint-inspect/v1",
"package": {
"schema_version": manifest.schema_version,
"kind": manifest.kind.as_str(),
"layers": manifest.layers.iter().map(PackageLayer::as_str).collect::<Vec<_>>(),
"name": manifest.name,
"version": manifest.version,
"package_digest": format!("sha256:{}", manifest.integrity.sha256),
"content_hash": format!("sha256:{}", manifest.integrity.content_hash),
"signature_state": signature_state,
"signer_public_key": manifest.signature.as_ref().map(|signature| &signature.public_key),
},
"checkpoint": checkpoint,
}))
.expect("inspect result serializes")
);
}

fn require_bounded_regular_file(path: &Path, max_bytes: u64, label: &str) {
let metadata = std::fs::symlink_metadata(path)
.unwrap_or_else(|error| fail(&format!("stat {label}: {error}")));
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes {
fail(&format!(
"{label} must be a bounded regular non-symlink file"
));
}
}

pub(super) fn cmd_pack_snapshot_v1_inspect(args: &[String]) {
use crate::core::context_snapshot::types::{
MAX_SNAPSHOT_LEDGER_ITEMS, MAX_SNAPSHOT_LINEAGE_ITEMS, MAX_SNAPSHOT_SESSION_LIST,
};

let file = args
.iter()
.find(|arg| !arg.starts_with("--") && arg.as_str() != "snapshot-v1-inspect")
.unwrap_or_else(|| fail("Usage: lean-ctx pack snapshot-v1-inspect <file.json>"));
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<String> {
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)
}
7 changes: 7 additions & 0 deletions rust/src/cli/pack_cmd/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod checkpoint;
mod management;
mod package;
mod pr;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -60,6 +64,9 @@ fn print_usage() {
\x20 export <name>[@version] [--output=<path>] [--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 <file.{ext}> [--apply] Import from file\n\
\x20 verify <file.{ext}> [...] Verify integrity + signature, no install (spec \u{a7}8/\u{a7}9; exit 1 on failure)\n\
\x20 checkpoint-seal --checkpoint=<payload.json> --output=<file.{ext}> --name=<name> [--version=<v>] [--unsigned]\n\
\x20 checkpoint-inspect <file.{ext}> Verify and emit the open checkpoint envelope as bounded JSON\n\
\x20 snapshot-v1-inspect <file.json> Verify bounded signed SnapshotV1 migration input\n\
\x20 install <name>[@version] [--file=<path>] Apply package to current project\n\
\x20 install <ns>/<name>[@version] Install from the hosted registry\n\
\x20 (ctxpkg.com; verifies sha256 + signature, pins in ctxpkg.lock,\n\
Expand Down
Loading
Loading