diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 37cfa5b3c6..12e0d5e608 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2452,6 +2452,7 @@ dependencies = [ "rpassword", "rusqlite", "rustls", + "semver", "serde", "serde_json", "serial_test", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a09a95c6c0..e5b168e8f4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -156,7 +156,6 @@ cloud-server = [ "dep:tokio-postgres", "dep:lettre", "dep:jsonwebtoken", - "dep:base64", "dep:uuid", "dep:hex", "dep:rand", @@ -339,7 +338,9 @@ lettre = { version = "0.11", default-features = false, features = [ "builder", ], optional = true } jsonwebtoken = { version = "10.4", optional = true } -base64 = { version = "0.22", optional = true } +# Non-optional since GH #727: `kind=skills` document blobs (featureless +# context_package core) encode zstd bodies as base64 inside the pack JSON. +base64 = "0.22" uuid = { version = "1.23", features = ["v4", "serde"], optional = true } hex = { version = "0.4", optional = true } hmac = "0.13" @@ -377,6 +378,7 @@ resvg = { version = "0.47", optional = true } wasmi = { version = "1.1", optional = true } gethostname = "1.1" yaml_serde = "0.10.4" +semver = "1.0.28" [lints.rust] unreachable_pub = "warn" @@ -477,3 +479,4 @@ harness = false [[bench]] name = "efficiency" harness = false + diff --git a/rust/src/cli/addon_cmd.rs b/rust/src/cli/addon_cmd.rs index 191b60ec00..6fb4d9bf2f 100644 --- a/rust/src/cli/addon_cmd.rs +++ b/rust/src/cli/addon_cmd.rs @@ -286,6 +286,7 @@ fn cmd_add(target: &str, args: &[String]) { || target.starts_with('.') || target.starts_with('/') || Path::new(target).exists(); + let mut pack_manifest: Option = None; let (manifest, source) = if is_local_path { match AddonManifest::from_path(Path::new(target)) { Ok(m) => (m, "local".to_string()), @@ -297,7 +298,10 @@ fn cmd_add(target: &str, args: &[String]) { } else if let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(target) { match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) { - Ok(pair) => pair, + Ok((m, s, pm)) => { + pack_manifest = Some(pm); + (m, s) + } Err(e) => { eprintln!("Error: {e}"); std::process::exit(1); @@ -352,6 +356,36 @@ fn cmd_add(target: &str, args: &[String]) { println!("About to install `{}`:\n", manifest.addon.name); print_install_preview(&manifest); + + // Depth-1 dependency resolution (GH #727): declared deps are part of the + // consent surface — resolve before asking, install after wiring succeeds. + let registry_base = crate::core::context_package::remote::registry_base( + flag_value(args, "--registry").as_deref(), + ); + let reg_token = crate::core::context_package::remote::publish_token(None); + let resolved_deps = match pack_manifest.as_ref() { + Some(pm) if pm.dependencies.iter().any(|d| !d.optional) => { + match crate::core::context_package::deps::resolve_dependencies( + pm, + ®istry_base, + reg_token.as_deref(), + ) { + Ok(v) => v, + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + } + _ => Vec::new(), + }; + if !resolved_deps.is_empty() { + println!("\nDeclared dependencies (installed alongside, depth-1):"); + for d in &resolved_deps { + println!(" + {}@{}", d.name, d.version); + } + } + println!( "\nThis runs/connects to the above MCP server and exposes its tools through lean-ctx." ); @@ -376,6 +410,23 @@ fn cmd_add(target: &str, args: &[String]) { if let Some(n) = verified { println!(" Verified: {n} tool(s) reachable."); } + if let Some(pm) = pack_manifest.as_ref().filter(|_| !resolved_deps.is_empty()) { + let project_root = super::common::detect_project_root(args); + if let Err(e) = super::pack_remote::install_declared_dependencies( + pm, + ®istry_base, + reg_token.as_deref(), + &project_root, + false, + ) { + eprintln!("Error: dependency install failed: {e}"); + eprintln!( + " The addon itself is wired; re-run `lean-ctx addon add {}` to retry.", + outcome.name + ); + std::process::exit(1); + } + } println!( " Its tools are reachable via `ctx_tools` (find/call). \ Restart your MCP client to pick them up." @@ -492,7 +543,14 @@ fn provision_and_wire( fn fetch_addon_pack( remote_ref: &crate::core::context_package::remote::RemoteRef, registry_flag: Option<&str>, -) -> Result<(AddonManifest, String), String> { +) -> Result< + ( + AddonManifest, + String, + crate::core::context_package::PackageManifest, + ), + String, +> { use crate::core::context_package::{remote, verify}; let base = remote::registry_base(registry_flag); @@ -534,25 +592,29 @@ fn fetch_addon_pack( content: crate::core::context_package::PackageContent, } let bundle: Bundle = serde_json::from_str(&text).map_err(|e| format!("parse package: {e}"))?; + let Bundle { + manifest: pack_manifest, + content, + } = bundle; - if bundle.manifest.kind != crate::core::context_package::manifest::PackageKind::Addon { + if pack_manifest.kind != crate::core::context_package::manifest::PackageKind::Addon { return Err(format!( "@{ns}/{name} is a kind={} package — install it with `lean-ctx pack install \ {ns}/{name}` instead", - bundle.manifest.kind.as_str() + pack_manifest.kind.as_str() )); } - verify::validate_kind_coherence(&bundle.manifest, &bundle.content) - .map_err(|errs| errs.join("; "))?; + verify::validate_kind_coherence(&pack_manifest, &content).map_err(|errs| errs.join("; "))?; - let payload = bundle - .content + let payload = content .addon .expect("coherence guarantees content.addon for kind=addon"); let manifest = AddonManifest::from_toml(&payload.manifest_toml)?; let source = format!("ctxpkg:@{ns}/{name}@{}", info.version); - Ok((manifest, source)) + // The pack manifest rides along for depth-1 dependency resolution + // (GH #727): declared skills/context deps install with the addon. + Ok((manifest, source, pack_manifest)) } /// `addon publish [manifest] --namespace ` — build the signed @@ -678,6 +740,7 @@ fn cmd_update(name: &str, args: &[String]) { // Re-resolve from where it came: a hosted ctxpkg pack updates against the // registry it was installed from (latest non-yanked version), everything // else against the bundled registry snapshot. + let mut pack_manifest: Option = None; let (manifest, update_source) = if let Some(spec) = entry.source.strip_prefix("ctxpkg:") { let unpinned = spec.split('@').take(2).collect::>().join("@"); let Some(remote_ref) = crate::core::context_package::remote::parse_remote_ref(&unpinned) @@ -689,7 +752,10 @@ fn cmd_update(name: &str, args: &[String]) { std::process::exit(1); }; match fetch_addon_pack(&remote_ref, flag_value(args, "--registry").as_deref()) { - Ok(pair) => pair, + Ok((m, s, pm)) => { + pack_manifest = Some(pm); + (m, s) + } Err(e) => { eprintln!("Error: {e}"); std::process::exit(1); @@ -728,6 +794,9 @@ fn cmd_update(name: &str, args: &[String]) { entry.version.clone() } ); + // A skills/context dependency may have bumped even when the addon + // itself did not (GH #727) — refresh those without re-wiring. + refresh_pack_dependencies(pack_manifest.as_ref(), args); return; } @@ -759,6 +828,7 @@ fn cmd_update(name: &str, args: &[String]) { if let Some(n) = verified { println!(" Verified: {n} tool(s) reachable."); } + refresh_pack_dependencies(pack_manifest.as_ref(), args); println!(" Restart your MCP client to pick up the new version."); } Err(e) => { @@ -768,6 +838,34 @@ fn cmd_update(name: &str, args: &[String]) { } } +/// Re-resolve and install the declared dependencies of an addon's pack +/// manifest (GH #727) — used on `addon update`, where a dependency can move +/// forward independently of the addon binary. +fn refresh_pack_dependencies( + pack_manifest: Option<&crate::core::context_package::PackageManifest>, + args: &[String], +) { + let Some(pm) = pack_manifest else { return }; + if pm.dependencies.iter().all(|d| d.optional) { + return; + } + let base = crate::core::context_package::remote::registry_base( + flag_value(args, "--registry").as_deref(), + ); + let token = crate::core::context_package::remote::publish_token(None); + let project_root = super::common::detect_project_root(args); + println!("Refreshing declared dependencies (depth-1) …"); + if let Err(e) = super::pack_remote::install_declared_dependencies( + pm, + &base, + token.as_deref(), + &project_root, + true, + ) { + eprintln!("Warning: dependency refresh failed: {e}\n The addon update itself succeeded."); + } +} + fn cmd_remove(name: &str, args: &[String]) { let Some(entry) = InstalledStore::load().get(name).cloned() else { eprintln!("Addon `{name}` is not installed."); diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index 1d2b2cfb01..dee055ce72 100644 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -28,6 +28,7 @@ mod ledger_cmd; mod output_savings_cmd; mod overview_cmd; mod pack_cmd; +mod pack_remote; pub mod plugin_cmd; mod policy_cmd; mod policy_enforce_cmd; diff --git a/rust/src/cli/pack_cmd.rs b/rust/src/cli/pack_cmd.rs index f400bd3e21..a2e761a6d2 100644 --- a/rust/src/cli/pack_cmd.rs +++ b/rust/src/cli/pack_cmd.rs @@ -35,6 +35,7 @@ pub(crate) fn cmd_pack(args: &[String]) { "pr" => cmd_pack_pr(args, &project_root), "create" => cmd_pack_create(args, &project_root), "install" => cmd_pack_install(args, &project_root), + "update" => super::pack_remote::cmd_pack_update(args, &project_root), "list" | "ls" => cmd_pack_list(), "info" => cmd_pack_info(args), "remove" | "rm" => cmd_pack_remove(args), @@ -145,6 +146,8 @@ fn cmd_pack_create(args: &[String], project_root: &str) { let mut level: u32 = 1; let mut scope: Option = None; let mut private = false; + let mut kind: Option = None; + let mut from_dir: Option = None; let mut i = 0; while i < args.len() { @@ -158,6 +161,28 @@ fn cmd_pack_create(args: &[String], project_root: &str) { i += 1; continue; } + if let Some(v) = a.strip_prefix("--kind=") { + kind = Some(v.to_string()); + i += 1; + continue; + } + if a == "--kind" { + i += 1; + kind = args.get(i).filter(|v| !v.starts_with("--")).cloned(); + i += 1; + continue; + } + if let Some(v) = a.strip_prefix("--from=") { + from_dir = Some(v.to_string()); + i += 1; + continue; + } + if a == "--from" { + i += 1; + from_dir = args.get(i).filter(|v| !v.starts_with("--")).cloned(); + i += 1; + continue; + } if let Some(v) = a.strip_prefix("--name=") { name = Some(v.to_string()); } else if a == "--name" { @@ -213,6 +238,37 @@ fn cmd_pack_create(args: &[String], project_root: &str) { return; }; + // kind=skills (GH #727): a content pack built from a directory of files, + // not from project stores — its own branch, everything else unchanged. + match kind.as_deref() { + None | Some("context") => {} + Some("skills") => { + let Some(dir) = from_dir else { + eprintln!("ERROR: --from is required for --kind skills"); + eprintln!( + "Usage: lean-ctx pack create --kind skills --name @ns/name --from ./skills-dir" + ); + return; + }; + create_skills_pack( + &pkg_name, + &version, + &description, + author.as_deref(), + tags, + &dir, + ); + return; + } + Some(other) => { + eprintln!( + "ERROR: unsupported --kind `{other}` for pack create (supported: context, skills)" + ); + eprintln!(" kind=addon packs are built with `lean-ctx addon publish --check`."); + return; + } + } + let requested_layers: Vec<&str> = layers_str.as_deref().map_or_else( || vec!["knowledge", "graph", "session", "gotchas"], |s| s.split(',').map(str::trim).collect(), @@ -339,6 +395,64 @@ fn cmd_pack_create(args: &[String], project_root: &str) { } } +/// `pack create --kind skills` — build, sign and register a content pack +/// from a directory of skill files (GH #727). +fn create_skills_pack( + name: &str, + version: &str, + description: &str, + author: Option<&str>, + tags: Vec, + dir: &str, +) { + use crate::core::context_package::skills; + + let plan = match skills::build_skills_pack( + std::path::Path::new(dir), + name, + version, + description, + author, + tags, + ) { + Ok(p) => p, + Err(e) => { + eprintln!("ERROR: {e}"); + return; + } + }; + + let registry = match crate::core::context_package::LocalRegistry::open() { + Ok(r) => r, + Err(e) => { + eprintln!("ERROR: cannot open registry: {e}"); + return; + } + }; + match registry.install(&plan.manifest, &plan.content) { + Ok(pkg_dir) => { + println!("Skills pack created successfully:"); + println!(" Name: {}", plan.name); + println!(" Version: {}", plan.version); + println!( + " Files: {} ({} plaintext)", + plan.file_count, + format_bytes(plan.total_bytes as u64) + ); + println!(" Signed: ed25519 (verify with `lean-ctx pack verify`)"); + println!(" Location: {}", pkg_dir.display()); + let skills_root = + skills::skills_dir(registry.root(), &plan.manifest.name, &plan.manifest.version); + println!(" Materialized: {}", skills_root.display()); + println!( + "\nPublish with: lean-ctx pack publish {}@{}", + plan.name, plan.version + ); + } + Err(e) => eprintln!("ERROR: install failed: {e}"), + } +} + fn cmd_pack_install(args: &[String], project_root: &str) { let mut pkg_name: Option = None; let mut pkg_version: Option = None; @@ -372,7 +486,7 @@ fn cmd_pack_install(args: &[String], project_root: &str) { match registry.import_from_file(std::path::Path::new(&file_path)) { Ok(manifest) => { println!("Imported: {} v{}", manifest.name, manifest.version); - apply_package(&manifest.name, &manifest.version, project_root); + apply_or_report(&manifest.name, &manifest.version, project_root); } Err(e) => eprintln!("ERROR: import failed: {e}"), } @@ -392,10 +506,11 @@ fn cmd_pack_install(args: &[String], project_root: &str) { Some(v) => format!("{name}@{v}"), None => name, }; - cmd_pack_install_remote( + super::pack_remote::cmd_pack_install_remote( &raw_ref, parse_flag(args, "--registry").as_deref(), project_root, + false, ); return; } @@ -426,7 +541,7 @@ fn cmd_pack_install(args: &[String], project_root: &str) { &resolved_version }; - apply_package(&name, version, project_root); + apply_or_report(&name, version, project_root); } fn apply_package(name: &str, version: &str, project_root: &str) { @@ -832,7 +947,7 @@ fn cmd_pack_import(args: &[String], project_root: &str) { println!(" Size: {}", format_bytes(manifest.integrity.byte_size)); if apply { - apply_package(&manifest.name, &manifest.version, project_root); + apply_or_report(&manifest.name, &manifest.version, project_root); } else { println!("\nTo apply this package to the current project:"); println!(" lean-ctx pack install {}", manifest.name); @@ -981,7 +1096,7 @@ fn cmd_pack_auto_load(args: &[String]) { } } -fn format_bytes(bytes: u64) -> String { +pub(crate) fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { @@ -1048,110 +1163,33 @@ fn cmd_pack_publish(args: &[String]) { } } -/// Install `ns/name[@version]` from the hosted registry: resolve the version, -/// download, verify the artifact hash against the index, then run the normal -/// import path (manifest validation + content integrity + local signature -/// re-verification) and pin the result in `.lean-ctx/ctxpkg.lock`. -fn cmd_pack_install_remote(raw_ref: &str, registry_flag: Option<&str>, project_root: &str) { - use crate::core::context_package::{LocalRegistry, lockfile, remote}; - - let Some(remote_ref) = remote::parse_remote_ref(raw_ref) else { - eprintln!("ERROR: '{raw_ref}' is not a valid ns/name[@version] reference"); - return; - }; - let base = remote::registry_base(registry_flag); - let ns = &remote_ref.namespace; - let name = &remote_ref.name; - // CTXPKG_TOKEN (ctxp_ or read-only ctxr_) unlocks private packages (#524). - let token = remote::publish_token(None); - - println!("Resolving @{ns}/{name} via {base} …"); - let versions = match remote::fetch_versions(&base, ns, name, token.as_deref()) { - Ok(v) => v, - Err(e) => { - eprintln!("ERROR: {e}"); - return; - } - }; - let info = match remote::select_version(&versions, remote_ref.version.as_deref()) { - Ok(i) => i, - Err(e) => { - eprintln!("ERROR: {e}"); - return; - } - }; - if info.yanked { - eprintln!( - "WARNING: @{ns}/{name}@{} is YANKED — installing only because the version \ - was pinned explicitly", - info.version - ); - } - - let bytes = match remote::download_verified(&base, ns, name, info, token.as_deref()) { - Ok(b) => b, - Err(e) => { - eprintln!("ERROR: {e}"); - return; +/// Apply a context pack to the project — or, for kind=skills, report where +/// the verified files were materialized (they load from disk, not sessions). +pub(crate) fn apply_or_report(name: &str, version: &str, project_root: &str) { + use crate::core::context_package::manifest::PackageKind; + + let kind = crate::core::context_package::LocalRegistry::open() + .ok() + .and_then(|r| r.load_package(name, version).ok()) + .map(|(m, _)| m.kind); + + if kind == Some(PackageKind::Skills) { + let root = crate::core::context_package::LocalRegistry::open() + .map(|r| crate::core::context_package::skills::skills_dir(r.root(), name, version)); + match root { + Ok(dir) => { + println!( + "Skills pack {name}@{version} materialized at {}", + dir.display() + ); + println!(" Files are read-only and SHA-256 verified against the manifest."); + } + Err(e) => eprintln!("ERROR: {e}"), } - }; - println!( - "Downloaded @{ns}/{name}@{} ({}, sha256 verified)", - info.version, - format_bytes(bytes.len() as u64) - ); - - // Hand the artifact to the standard import path via a temp file so every - // local gate (extension, size cap, manifest validation, content integrity) - // applies identically to remote and local installs. - let tmp = std::env::temp_dir().join(format!("ctxpkg-install-{}.ctxpkg", std::process::id())); - if let Err(e) = std::fs::write(&tmp, &bytes) { - eprintln!("ERROR: stage artifact: {e}"); return; } - let imported = (|| { - let registry = LocalRegistry::open()?; - registry.import_from_file(&tmp) - })(); - std::fs::remove_file(&tmp).ok(); - - let manifest = match imported { - Ok(m) => m, - Err(e) => { - eprintln!("ERROR: import failed: {e}"); - return; - } - }; - - // Registry compromise ≠ client compromise: re-verify the signature locally. - match crate::core::context_package::verify_signature(&manifest) { - Ok(true) => println!("Signature: ed25519 verified locally"), - Ok(false) => { - eprintln!( - "WARNING: package is unsigned — the hosted registry should not have accepted it" - ); - } - Err(e) => { - eprintln!("ERROR: signature verification failed: {e}"); - return; - } - } - - if let Err(e) = lockfile::upsert( - std::path::Path::new(project_root), - lockfile::LockedPackage { - name: manifest.name.clone(), - version: manifest.version.clone(), - artifact_sha256: info.artifact_sha256.clone(), - registry: base, - }, - ) { - eprintln!("WARNING: could not update ctxpkg.lock: {e}"); - } else { - println!("Pinned in {}", lockfile::LOCKFILE_REL_PATH); - } - apply_package(&manifest.name, &manifest.version, project_root); + apply_package(name, version, project_root); } fn cmd_pack_send(args: &[String], project_root: &str) { @@ -1321,7 +1359,7 @@ fn cmd_pack_receive(args: &[String], project_root: &str) { match registry.import_from_file(&tmp) { Ok(manifest) => { eprintln!("Imported: {} v{}", manifest.name, manifest.version); - apply_package(&manifest.name, &manifest.version, project_root); + apply_or_report(&manifest.name, &manifest.version, project_root); } Err(e) => eprintln!("ERROR: import failed: {e}"), } @@ -1353,7 +1391,7 @@ fn cmd_pack_receive(args: &[String], project_root: &str) { } /// Parse `--flag=value` or `--flag value` from args. -fn parse_flag(args: &[String], flag: &str) -> Option { +pub(crate) fn parse_flag(args: &[String], flag: &str) -> Option { let prefix = format!("{flag}="); let mut iter = args.iter(); while let Some(a) = iter.next() { @@ -1378,6 +1416,7 @@ fn print_usage() { \n\ Create & Manage:\n\ \x20 create --name [--version ] [--level 1|2|3] [--scope @ns] [--description ] [--author ] [--tags ] [--layers ]\n\ + \x20 create --kind skills --name @ns/ --from --description Build a signed skills pack from a directory\n\ \x20 list List all installed packages\n\ \x20 info [@version] Show package details\n\ \x20 remove [@version] Remove a package\n\ @@ -1388,7 +1427,9 @@ fn print_usage() { \x20 verify [...] Verify integrity + signature, no install (spec \u{a7}8/\u{a7}9; exit 1 on failure)\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\ + \x20 (ctxpkg.com; verifies sha256 + signature, pins in ctxpkg.lock,\n\ + \x20 resolves declared dependencies depth-1)\n\ + \x20 update / Refresh a hosted pack + its dependencies to the newest versions\n\ \x20 publish [--registry ] [--token ] Publish (signed, scoped @ns/name)\n\ \n\ A2A Transport:\n\ diff --git a/rust/src/cli/pack_remote.rs b/rust/src/cli/pack_remote.rs new file mode 100644 index 0000000000..50a011df8b --- /dev/null +++ b/rust/src/cli/pack_remote.rs @@ -0,0 +1,258 @@ +//! Hosted-registry installs for `lean-ctx pack` (GL #406, GH #727). +//! +//! `pack install /` / `pack update /`: version resolution +//! against the registry index, sha256-verified download, the standard local +//! import gates, lockfile pinning — and depth-1 resolution of the declared +//! dependencies so one install command yields a complete, reproducible set. + +use super::pack_cmd::{apply_or_report, format_bytes, parse_flag}; + +/// Install `ns/name[@version]` from the hosted registry: resolve the version, +/// download, verify the artifact hash against the index, then run the normal +/// import path (manifest validation + content integrity + local signature +/// re-verification) and pin the result in `.lean-ctx/ctxpkg.lock`. +pub(crate) fn cmd_pack_install_remote( + raw_ref: &str, + registry_flag: Option<&str>, + project_root: &str, + refresh: bool, +) { + use crate::core::context_package::{LocalRegistry, deps, lockfile, remote}; + + let Some(remote_ref) = remote::parse_remote_ref(raw_ref) else { + eprintln!("ERROR: '{raw_ref}' is not a valid ns/name[@version] reference"); + return; + }; + let base = remote::registry_base(registry_flag); + let ns = &remote_ref.namespace; + let name = &remote_ref.name; + // CTXPKG_TOKEN (ctxp_ or read-only ctxr_) unlocks private packages (#524). + let token = remote::publish_token(None); + + // Offline-reproducible installs (GH #727): an unpinned re-install that is + // already locked (or already imported into the store) never touches the + // network. `pack update` (refresh=true) and explicit `@version` pins skip + // this fast path. + if !refresh && remote_ref.version.is_none() { + let scoped = format!("@{ns}/{name}"); + let candidate = + deps::locked_version(&scoped, std::path::Path::new(project_root)).or_else(|| { + LocalRegistry::open() + .ok() + .and_then(|r| r.list().ok()) + .and_then(|entries| { + entries + .iter() + .filter(|e| e.name == scoped) + .max_by(|a, b| a.installed_at.cmp(&b.installed_at)) + .map(|e| e.version.clone()) + }) + }); + if let Some(version) = candidate { + let in_store = LocalRegistry::open() + .ok() + .and_then(|r| r.get(&scoped, Some(&version)).ok().flatten()) + .is_some(); + if in_store { + println!("Using installed {scoped}@{version} from the local store (offline)."); + println!(" (run `lean-ctx pack update {ns}/{name}` to fetch a newer version)"); + apply_or_report(&scoped, &version, project_root); + return; + } + } + } + + println!("Resolving @{ns}/{name} via {base} …"); + let versions = match remote::fetch_versions(&base, ns, name, token.as_deref()) { + Ok(v) => v, + Err(e) => { + eprintln!("ERROR: {e}"); + return; + } + }; + let info = match remote::select_version(&versions, remote_ref.version.as_deref()) { + Ok(i) => i, + Err(e) => { + eprintln!("ERROR: {e}"); + return; + } + }; + if info.yanked { + eprintln!( + "WARNING: @{ns}/{name}@{} is YANKED — installing only because the version \ + was pinned explicitly", + info.version + ); + } + + let bytes = match remote::download_verified(&base, ns, name, info, token.as_deref()) { + Ok(b) => b, + Err(e) => { + eprintln!("ERROR: {e}"); + return; + } + }; + println!( + "Downloaded @{ns}/{name}@{} ({}, sha256 verified)", + info.version, + format_bytes(bytes.len() as u64) + ); + + // Hand the artifact to the standard import path via a temp file so every + // local gate (extension, size cap, manifest validation, content integrity) + // applies identically to remote and local installs. + let tmp = std::env::temp_dir().join(format!("ctxpkg-install-{}.ctxpkg", std::process::id())); + if let Err(e) = std::fs::write(&tmp, &bytes) { + eprintln!("ERROR: stage artifact: {e}"); + return; + } + let imported = (|| { + let registry = LocalRegistry::open()?; + registry.import_from_file(&tmp) + })(); + std::fs::remove_file(&tmp).ok(); + + let manifest = match imported { + Ok(m) => m, + Err(e) => { + eprintln!("ERROR: import failed: {e}"); + return; + } + }; + + // Registry compromise ≠ client compromise: re-verify the signature locally. + match crate::core::context_package::verify_signature(&manifest) { + Ok(true) => println!("Signature: ed25519 verified locally"), + Ok(false) => { + eprintln!( + "WARNING: package is unsigned — the hosted registry should not have accepted it" + ); + } + Err(e) => { + eprintln!("ERROR: signature verification failed: {e}"); + return; + } + } + + if let Err(e) = lockfile::upsert( + std::path::Path::new(project_root), + lockfile::LockedPackage { + name: manifest.name.clone(), + version: manifest.version.clone(), + artifact_sha256: info.artifact_sha256.clone(), + registry: base.clone(), + }, + ) { + eprintln!("WARNING: could not update ctxpkg.lock: {e}"); + } else { + println!("Pinned in {}", lockfile::LOCKFILE_REL_PATH); + } + + // Depth-1 dependency resolution (GH #727): declared, non-optional deps + // install from the same registry and land in the same lockfile. + if let Err(e) = + install_declared_dependencies(&manifest, &base, token.as_deref(), project_root, refresh) + { + eprintln!("ERROR: dependency install failed: {e}"); + eprintln!( + " `{}` itself is installed; fix the dependency and re-run.", + manifest.name + ); + return; + } + + apply_or_report(&manifest.name, &manifest.version, project_root); +} + +/// Install every non-optional declared dependency of `manifest` (depth 1, +/// GH #727). Already-locked deps present in the store are skipped offline; +/// everything else resolves SemVer against the registry, downloads through +/// the standard verified import path, and is pinned in the lockfile. +pub(crate) fn install_declared_dependencies( + manifest: &crate::core::context_package::PackageManifest, + base: &str, + token: Option<&str>, + project_root: &str, + refresh: bool, +) -> Result<(), String> { + use crate::core::context_package::{LocalRegistry, deps, lockfile, remote}; + + if manifest.dependencies.iter().all(|d| d.optional) { + return Ok(()); + } + let registry = LocalRegistry::open()?; + let root = std::path::Path::new(project_root); + + for dep in manifest.dependencies.iter().filter(|d| !d.optional) { + if !refresh && let Some(ver) = deps::already_satisfied(root, ®istry, dep) { + println!( + "Dependency {}@{ver} already satisfied (locked, offline).", + dep.name + ); + continue; + } + + let resolved = deps::resolve_one(&manifest.name, dep, base, token)?; + let (ns, slug) = (&resolved.namespace, &resolved.slug); + println!( + "Installing dependency @{ns}/{slug}@{} (declared: `{} {}`)", + resolved.version, dep.name, dep.version_req + ); + + let info = remote::VersionInfo { + version: resolved.version.clone(), + artifact_sha256: resolved.artifact_sha256.clone(), + yanked: false, + }; + let bytes = remote::download_verified(base, ns, slug, &info, token)?; + let tmp = std::env::temp_dir().join(format!( + "ctxpkg-dep-{}-{ns}-{slug}.ctxpkg", + std::process::id() + )); + std::fs::write(&tmp, &bytes).map_err(|e| format!("stage dependency artifact: {e}"))?; + let imported = registry.import_from_file(&tmp); + std::fs::remove_file(&tmp).ok(); + let dep_manifest = imported.map_err(|e| format!("dependency `{}`: {e}", dep.name))?; + + if let Err(e) = lockfile::upsert( + root, + lockfile::LockedPackage { + name: dep_manifest.name.clone(), + version: dep_manifest.version.clone(), + artifact_sha256: resolved.artifact_sha256.clone(), + registry: base.to_string(), + }, + ) { + eprintln!("WARNING: could not update ctxpkg.lock: {e}"); + } + println!( + " ✓ {}@{} installed + pinned", + dep_manifest.name, dep_manifest.version + ); + } + Ok(()) +} + +/// `pack update /` — refresh a hosted pack (and its declared +/// dependencies) to the newest matching versions, updating the lockfile. +pub(crate) fn cmd_pack_update(args: &[String], project_root: &str) { + let target = args + .iter() + .skip_while(|a| a.as_str() != "update") + .skip(1) + .find(|a| !a.starts_with("--")); + let Some(raw_ref) = target else { + eprintln!("Usage: lean-ctx pack update / [--registry ]"); + return; + }; + if crate::core::context_package::remote::parse_remote_ref(raw_ref).is_none() { + eprintln!("ERROR: '{raw_ref}' is not a valid ns/name reference"); + return; + } + cmd_pack_install_remote( + raw_ref, + parse_flag(args, "--registry").as_deref(), + project_root, + true, + ); +} diff --git a/rust/src/core/context_package/content.rs b/rust/src/core/context_package/content.rs index 8fbb2e82f8..1fe764280c 100644 --- a/rust/src/core/context_package/content.rs +++ b/rust/src/core/context_package/content.rs @@ -24,6 +24,11 @@ pub struct PackageContent { /// [`super::verify::validate_kind_coherence`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub addon: Option, + /// `kind=skills` payload (GH #724/#727): named, verified content blobs. + /// Absent for every other kind — enforced by + /// [`super::verify::validate_kind_coherence`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub documents: Option, } /// Distribution view of an addon (unified distribution, GH #726): the @@ -38,6 +43,107 @@ pub struct AddonContent { pub manifest_toml: String, } +/// `kind=skills` payload (GH #727): a set of named, verified content blobs +/// (markdown/scripts). **No execution semantics in lean-ctx** — skills are +/// verified *content*; interpretation belongs to the consumer (an addon like +/// lean-md, or the agent itself). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DocumentsContent { + /// Sorted by `path` (byte order) — deterministic pack bytes (#498). + pub files: Vec, +} + +/// Body encoding marker for [`DocumentBlob::body`]. The only supported value; +/// a field (not an enum) so future encodings fail with "unsupported encoding" +/// on old readers instead of a serde parse error. +pub const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64"; + +/// Per-file caps (plaintext bytes) — a skills pack is documentation and +/// scripts, not a media archive. +pub const MAX_DOCUMENT_FILES: usize = 256; +pub const MAX_DOCUMENT_FILE_BYTES: usize = 1024 * 1024; +pub const MAX_DOCUMENTS_TOTAL_BYTES: usize = 8 * 1024 * 1024; + +/// One named blob: `path` + SHA-256 of the **plaintext** + compressed body. +/// The hash pins the decoded bytes, so tampering with the stored body (or a +/// decompression bug) is detected before anything lands on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DocumentBlob { + /// Relative, `/`-separated path inside the pack (e.g. `skills/review.md`). + pub path: String, + /// SHA-256 (lowercase hex) of the plaintext bytes. + pub sha256: String, + /// Body encoding — currently always [`DOCUMENT_ENCODING_ZSTD_B64`]. + pub encoding: String, + /// base64(zstd(plaintext)). + pub body: String, +} + +impl DocumentBlob { + /// Build a blob from plaintext bytes (deterministic: fixed zstd level). + pub fn from_plaintext(path: &str, bytes: &[u8]) -> Result { + let compressed = + zstd::encode_all(bytes, 3).map_err(|e| format!("zstd compress {path}: {e}"))?; + Ok(Self { + path: path.to_string(), + sha256: sha256_hex_of(bytes), + encoding: DOCUMENT_ENCODING_ZSTD_B64.to_string(), + body: base64_encode(&compressed), + }) + } + + /// Decode and verify the body against its `sha256` pin. Any mismatch — + /// tampered body, wrong hash, corrupt compression — is an error; callers + /// never see unverified bytes. + pub fn decode_verified(&self) -> Result, String> { + if self.encoding != DOCUMENT_ENCODING_ZSTD_B64 { + return Err(format!( + "`{}`: unsupported encoding `{}` (newer lean-ctx required)", + self.path, self.encoding + )); + } + let compressed = base64_decode(&self.body) + .map_err(|e| format!("`{}`: body is not valid base64: {e}", self.path))?; + // Cap the decompressed size before allocating: a hostile blob must + // not zstd-bomb the installer. + let plain = zstd::bulk::decompress(&compressed, MAX_DOCUMENT_FILE_BYTES + 1) + .map_err(|e| format!("`{}`: zstd decompress failed: {e}", self.path))?; + if plain.len() > MAX_DOCUMENT_FILE_BYTES { + return Err(format!( + "`{}`: decoded size exceeds the {} byte cap", + self.path, MAX_DOCUMENT_FILE_BYTES + )); + } + let actual = sha256_hex_of(&plain); + if !actual.eq_ignore_ascii_case(&self.sha256) { + return Err(format!( + "`{}`: content hash mismatch — expected {}, got {actual} (tampered blob)", + self.path, self.sha256 + )); + } + Ok(plain) + } +} + +fn sha256_hex_of(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(bytes); + crate::core::agent_identity::hex_encode(&h.finalize()) +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn base64_decode(text: &str) -> Result, String> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(text.trim()) + .map_err(|e| e.to_string()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnowledgeLayer { pub facts: Vec, @@ -151,6 +257,9 @@ impl PackageContent { if self.addon.is_some() { n += 1; } + if self.documents.is_some() { + n += 1; + } n } diff --git a/rust/src/core/context_package/deps.rs b/rust/src/core/context_package/deps.rs new file mode 100644 index 0000000000..63358e2563 --- /dev/null +++ b/rust/src/core/context_package/deps.rs @@ -0,0 +1,292 @@ +//! Depth-1 dependency resolution at install time (GH #727, Phase 3). +//! +//! A package may declare [`PackageDependency`] entries (SemVer ranges). On +//! `pack install` / `addon add`, the direct dependencies of the root package +//! are resolved against the registry index and installed alongside it — one +//! consent surface listing everything that will land. +//! +//! **Depth-1 is deliberate** (issue non-goal: no transitive graphs): only the +//! root's own dependencies resolve; a dependency's dependencies do not. That +//! keeps resolution O(deps), makes cycles impossible beyond self-reference +//! (which is refused), and keeps the consent prompt honest — nothing installs +//! that was not listed. +//! +//! Determinism: given the same registry index, resolution always picks the +//! **highest non-yanked version matching the range** — and repeated installs +//! short-circuit offline via the lockfile + local store (`already_satisfied`). + +use super::manifest::{PackageDependency, PackageManifest}; +use super::remote::{self, VersionInfo}; + +/// One resolved direct dependency, ready to download. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedDep { + /// Scoped name as declared (`@ns/name`). + pub name: String, + /// Registry namespace (without `@`). + pub namespace: String, + /// Bare package name (slug). + pub slug: String, + /// The picked version (highest non-yanked match of the range). + pub version: String, + /// Artifact hash from the registry index (verified again on download). + pub artifact_sha256: String, +} + +/// Resolve the direct, non-optional dependencies of `manifest` against the +/// registry at `base`. Fails on: unscoped names, self-dependency, invalid +/// ranges, and ranges with no installable match — a partially-resolved +/// install is worse than a refused one. +pub fn resolve_dependencies( + manifest: &PackageManifest, + base: &str, + token: Option<&str>, +) -> Result, String> { + let mut resolved = Vec::new(); + for dep in &manifest.dependencies { + if dep.optional { + continue; + } + resolved.push(resolve_one(&manifest.name, dep, base, token)?); + } + Ok(resolved) +} + +/// Resolve a single declared dependency against the registry index. +pub fn resolve_one( + root_name: &str, + dep: &PackageDependency, + base: &str, + token: Option<&str>, +) -> Result { + let Some(remote_ref) = remote::parse_remote_ref(&dep.name) else { + return Err(format!( + "dependency `{}` is not a scoped @ns/name reference — unresolvable", + dep.name + )); + }; + if dep.name.trim_start_matches('@') == root_name.trim_start_matches('@') { + return Err(format!( + "package depends on itself (`{}`) — refused", + dep.name + )); + } + let req = parse_version_req(&dep.version_req) + .map_err(|e| format!("dependency `{}`: {e}", dep.name))?; + + let versions = remote::fetch_versions(base, &remote_ref.namespace, &remote_ref.name, token) + .map_err(|e| format!("dependency `{}`: {e}", dep.name))?; + let best = pick_highest_match(&versions, &req).ok_or_else(|| { + format!( + "dependency `{}`: no installable version matches `{}` (available: {})", + dep.name, + dep.version_req, + versions + .iter() + .map(|v| v.version.as_str()) + .collect::>() + .join(", ") + ) + })?; + + Ok(ResolvedDep { + name: dep.name.clone(), + namespace: remote_ref.namespace, + slug: remote_ref.name, + version: best.version.clone(), + artifact_sha256: best.artifact_sha256.clone(), + }) +} + +/// Parse a SemVer range. An empty/`*` requirement means "any version". +pub fn parse_version_req(req: &str) -> Result { + let trimmed = req.trim(); + if trimmed.is_empty() || trimmed == "*" { + return Ok(semver::VersionReq::STAR); + } + semver::VersionReq::parse(trimmed).map_err(|e| format!("invalid version range `{req}`: {e}")) +} + +/// Highest non-yanked version matching `req`. Non-SemVer versions in the +/// index are skipped (they can never match a range). +pub fn pick_highest_match<'a>( + versions: &'a [VersionInfo], + req: &semver::VersionReq, +) -> Option<&'a VersionInfo> { + versions + .iter() + .filter(|v| !v.yanked) + .filter_map(|v| Some((semver::Version::parse(&v.version).ok()?, v))) + .filter(|(parsed, _)| req.matches(parsed)) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, v)| v) +} + +/// Version of `name` pinned in the project lockfile, if any. +pub fn locked_version(name: &str, project_root: &std::path::Path) -> Option { + let lock = super::lockfile::load(project_root).ok()?; + lock.packages + .iter() + .find(|p| p.name == name) + .map(|p| p.version.clone()) +} + +/// True when `name@version-satisfying-req` is already pinned in the lockfile +/// **and** present in the local store — the offline-reproducible fast path: +/// a second `pack install` touches no network for satisfied dependencies. +pub fn already_satisfied( + project_root: &std::path::Path, + registry: &super::registry::LocalRegistry, + dep: &PackageDependency, +) -> Option { + let lock = super::lockfile::load(project_root).ok()?; + let locked = lock.packages.iter().find(|p| p.name == dep.name)?; + let req = parse_version_req(&dep.version_req).ok()?; + let version = semver::Version::parse(&locked.version).ok()?; + if !req.matches(&version) { + return None; + } + let installed = registry.get(&dep.name, Some(&locked.version)).ok()??; + Some(installed.version) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(version: &str, yanked: bool) -> VersionInfo { + VersionInfo { + version: version.into(), + artifact_sha256: "a".repeat(64), + yanked, + } + } + + #[test] + fn picks_highest_matching_version() { + let versions = [v("1.0.0", false), v("1.2.0", false), v("2.0.0", false)]; + let req = parse_version_req("^1.0").unwrap(); + assert_eq!( + pick_highest_match(&versions, &req).unwrap().version, + "1.2.0" + ); + } + + #[test] + fn yanked_versions_never_match() { + let versions = [v("1.0.0", false), v("1.3.0", true)]; + let req = parse_version_req("^1.0").unwrap(); + assert_eq!( + pick_highest_match(&versions, &req).unwrap().version, + "1.0.0" + ); + } + + #[test] + fn no_match_yields_none() { + let versions = [v("1.0.0", false)]; + let req = parse_version_req("^2.0").unwrap(); + assert!(pick_highest_match(&versions, &req).is_none()); + } + + #[test] + fn star_and_empty_match_anything() { + let versions = [v("0.3.7", false)]; + for raw in ["", "*", " "] { + let req = parse_version_req(raw).unwrap(); + assert_eq!( + pick_highest_match(&versions, &req).unwrap().version, + "0.3.7", + "req `{raw}`" + ); + } + } + + #[test] + fn non_semver_index_entries_are_skipped() { + let versions = [v("not-a-version", false), v("1.1.0", false)]; + let req = parse_version_req("^1").unwrap(); + assert_eq!( + pick_highest_match(&versions, &req).unwrap().version, + "1.1.0" + ); + } + + #[test] + fn invalid_range_is_an_error() { + assert!(parse_version_req(">>nope<<").is_err()); + } + + #[test] + fn self_dependency_is_refused() { + let mut manifest = crate::core::context_package::manifest::PackageManifest { + dependencies: vec![PackageDependency { + name: "@acme/root".into(), + version_req: "^1".into(), + optional: false, + }], + ..minimal("@acme/root") + }; + // resolve_one is exercised via resolve_dependencies; the self-check + // fires before any network I/O, so an invalid base URL never matters. + let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err(); + assert!(err.contains("depends on itself"), "got: {err}"); + + // Optional dependencies are skipped entirely. + manifest.dependencies[0].optional = true; + assert_eq!( + resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap(), + Vec::new() + ); + } + + #[test] + fn unscoped_dependency_is_refused() { + let manifest = crate::core::context_package::manifest::PackageManifest { + dependencies: vec![PackageDependency { + name: "plain-name".into(), + version_req: "^1".into(), + optional: false, + }], + ..minimal("@acme/root") + }; + let err = resolve_dependencies(&manifest, "http://127.0.0.1:1", None).unwrap_err(); + assert!(err.contains("not a scoped"), "got: {err}"); + } + + fn minimal(name: &str) -> crate::core::context_package::manifest::PackageManifest { + use crate::core::context_package::manifest::*; + PackageManifest { + schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION, + conformance_level: None, + kind: PackageKind::default(), + name: name.into(), + version: "1.0.0".into(), + description: "d".into(), + author: None, + scope: None, + created_at: chrono::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".into(), + project_hash: None, + source_session_id: None, + }, + compatibility: CompatibilitySpec::default(), + stats: PackageStats::default(), + signature: None, + graph_summary: None, + marketplace: None, + } + } +} diff --git a/rust/src/core/context_package/mod.rs b/rust/src/core/context_package/mod.rs index 4613049c03..202fe0649c 100644 --- a/rust/src/core/context_package/mod.rs +++ b/rust/src/core/context_package/mod.rs @@ -3,6 +3,7 @@ pub mod builder; pub mod bundle; pub mod composition; pub mod content; +pub mod deps; pub mod export; pub mod graph_model; pub mod import; @@ -13,6 +14,7 @@ pub mod manifest; pub mod registry; pub mod remote; pub mod signing; +pub mod skills; pub mod verify; pub use auto_load::auto_load_packages; diff --git a/rust/src/core/context_package/registry.rs b/rust/src/core/context_package/registry.rs index ec0400c2d2..cd3fcce190 100644 --- a/rust/src/core/context_package/registry.rs +++ b/rust/src/core/context_package/registry.rs @@ -71,6 +71,23 @@ impl LocalRegistry { ) -> Result { manifest.validate().map_err(|errs| errs.join("; "))?; + // kind=skills (GH #727): materialize the verified blobs under the + // store *before* touching the index — a tampered blob aborts the + // whole install, never leaving a half-registered package. + if manifest.kind == super::manifest::PackageKind::Skills { + let docs = content + .documents + .as_ref() + .ok_or("kind=skills package has no documents payload")?; + let root = super::skills::materialize_documents(&self.root, manifest, docs)?; + tracing::info!( + "skills pack {}@{} materialized at {}", + manifest.name, + manifest.version, + root.display() + ); + } + let pkg_dir = self.package_dir(&manifest.name, &manifest.version); std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?; @@ -121,6 +138,12 @@ impl LocalRegistry { if dir.exists() { let _ = std::fs::remove_dir_all(&dir); } + // Materialized skills files (GH #727) live outside the package + // dir — clean them up with the same lifetime. + let skills = super::skills::skills_dir(&self.root, n, v); + if skills.exists() { + let _ = std::fs::remove_dir_all(&skills); + } } index.entries.retain(|e| { diff --git a/rust/src/core/context_package/skills.rs b/rust/src/core/context_package/skills.rs new file mode 100644 index 0000000000..b90928ad2f --- /dev/null +++ b/rust/src/core/context_package/skills.rs @@ -0,0 +1,438 @@ +//! `kind=skills` pack builder (GH #724/#727, Phase 3) — the first signed, +//! versioned, updatable skill channel. +//! +//! A skills pack is a directory of markdown/scripts turned into named, +//! verified content blobs. **No execution semantics in lean-ctx**: skills are +//! content; interpretation belongs to the consumer (an addon like lean-md, or +//! the agent itself). This closes the `include_str!` problem for addon +//! binaries — content updates ship without a binary release. +//! +//! Determinism (#498): the content bytes are a pure function of the input +//! directory — files are sorted by path (byte order), zstd runs at a fixed +//! level, and nothing time- or environment-dependent enters the payload. + +use std::path::{Path, PathBuf}; + +use chrono::Utc; + +use super::content::{ + DocumentBlob, DocumentsContent, MAX_DOCUMENT_FILE_BYTES, MAX_DOCUMENT_FILES, + MAX_DOCUMENTS_TOTAL_BYTES, PackageContent, +}; +use super::manifest::{ + CompatibilitySpec, PackageIntegrity, PackageKind, PackageManifest, PackageProvenance, + PackageStats, +}; +use super::{keys, signing, verify}; + +/// Everything `pack create --kind skills` produces: the signed bundle plus +/// the facts the CLI discloses. No network I/O. +#[derive(Debug)] +pub struct SkillsPackPlan { + pub name: String, + pub version: String, + /// The signed `.ctxpkg` document (pretty JSON, ready for store/upload). + pub bundle_json: String, + pub manifest: PackageManifest, + pub content: PackageContent, + /// Number of files and total plaintext bytes packed. + pub file_count: usize, + pub total_bytes: usize, +} + +/// Build and sign a `kind=skills` pack from a directory. +/// +/// Collects every regular file under `dir` (hidden files/dirs and VCS +/// metadata are skipped), sorted by relative path for deterministic bytes. +pub fn build_skills_pack( + dir: &Path, + name: &str, + version: &str, + description: &str, + author: Option<&str>, + tags: Vec, +) -> Result { + if !dir.is_dir() { + return Err(format!("`{}` is not a directory", dir.display())); + } + if description.trim().is_empty() { + return Err("a description is required for a skills pack".into()); + } + + let mut rel_paths = collect_files(dir)?; + rel_paths.sort(); + if rel_paths.is_empty() { + return Err(format!( + "`{}` contains no packable files (hidden files and VCS dirs are skipped)", + dir.display() + )); + } + if rel_paths.len() > MAX_DOCUMENT_FILES { + return Err(format!( + "{} files exceed the {MAX_DOCUMENT_FILES}-file cap for a skills pack", + rel_paths.len() + )); + } + + let mut files = Vec::with_capacity(rel_paths.len()); + let mut total = 0usize; + for rel in &rel_paths { + verify::validate_document_path(rel)?; + let bytes = std::fs::read(dir.join(rel)).map_err(|e| format!("read {rel}: {e}"))?; + if bytes.len() > MAX_DOCUMENT_FILE_BYTES { + return Err(format!( + "`{rel}` is {} bytes (per-file cap: {MAX_DOCUMENT_FILE_BYTES})", + bytes.len() + )); + } + total += bytes.len(); + files.push(DocumentBlob::from_plaintext(rel, &bytes)?); + } + if total > MAX_DOCUMENTS_TOTAL_BYTES { + return Err(format!( + "pack decodes to {total} bytes (cap: {MAX_DOCUMENTS_TOTAL_BYTES})" + )); + } + + let content = PackageContent { + documents: Some(DocumentsContent { files }), + ..PackageContent::default() + }; + + // Integrity exactly like the context/addon builders: compact content JSON + // is the hashed byte stream, the package hash chains name+version onto it. + let content_json = serde_json::to_string(&content).map_err(|e| e.to_string())?; + let content_hash = sha256_hex(content_json.as_bytes()); + let sha256 = sha256_hex(format!("{name}:{version}:{content_hash}").as_bytes()); + + let mut manifest = PackageManifest { + schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION, + conformance_level: None, + kind: PackageKind::Skills, + name: name.to_string(), + version: version.to_string(), + description: description.to_string(), + author: author.map(str::to_string), + scope: name + .starts_with('@') + .then(|| name.split('/').next().unwrap_or_default().to_string()), + created_at: Utc::now(), + updated_at: None, + layers: Vec::new(), + dependencies: Vec::new(), + tags, + visibility: None, + integrity: PackageIntegrity { + sha256, + content_hash, + byte_size: content_json.len() as u64, + }, + provenance: PackageProvenance { + tool: "lean-ctx".into(), + tool_version: env!("CARGO_PKG_VERSION").into(), + project_hash: None, + source_session_id: None, + }, + compatibility: CompatibilitySpec::default(), + stats: PackageStats::default(), + signature: None, + graph_summary: None, + marketplace: None, + }; + manifest.validate().map_err(|errs| errs.join("; "))?; + verify::validate_kind_coherence(&manifest, &content).map_err(|errs| errs.join("; "))?; + + let (signing_key, created) = keys::load_or_create()?; + if created { + tracing::info!("ctxpkg: created a new ed25519 signing key for this machine"); + } + signing::sign_package(&mut manifest, &content, &signing_key); + + // Typed bundle (not `json!`): serde keeps struct field order, so the + // content text stays byte-identical to what was hashed above. + #[derive(serde::Serialize)] + struct Bundle<'a> { + manifest: &'a PackageManifest, + content: &'a PackageContent, + } + let bundle_json = serde_json::to_string_pretty(&Bundle { + manifest: &manifest, + content: &content, + }) + .map_err(|e| e.to_string())?; + + // Self-check: the exact bytes we would ship must verify cleanly. + let self_check = verify::verify_package_text(&bundle_json); + if !self_check.valid() { + return Err(format!( + "internal error — the built pack fails verification: {}", + self_check.errors.join("; ") + )); + } + + Ok(SkillsPackPlan { + name: name.to_string(), + version: version.to_string(), + bundle_json, + manifest, + content, + file_count: rel_paths.len(), + total_bytes: total, + }) +} + +/// Recursively collect relative `/`-separated file paths under `root`. +/// Hidden entries (dotfiles) and VCS/tooling dirs are skipped — a skills pack +/// is authored content, not a repository snapshot. +fn collect_files(root: &Path) -> Result, String> { + fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), String> { + let entries = + std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || name == "node_modules" || name == "target" { + continue; + } + let ft = entry.file_type().map_err(|e| e.to_string())?; + // Symlinks are skipped, not followed: a link pointing outside the + // directory must never leak foreign file content into the pack. + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(root, &path, out)?; + } else if ft.is_file() { + let rel = path + .strip_prefix(root) + .map_err(|e| e.to_string())? + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + out.push(rel); + } + } + Ok(()) + } + let mut out = Vec::new(); + walk(root, root, &mut out)?; + Ok(out) +} + +/// Materialize a verified skills payload under the pack store: +/// `/skills///`. Every blob is decoded through +/// [`DocumentBlob::decode_verified`] — a tampered body aborts the install +/// before anything lands. Files are written read-only; the returned path is +/// the version root the consumer reads from. +pub fn materialize_documents( + store_root: &Path, + manifest: &PackageManifest, + docs: &DocumentsContent, +) -> Result { + let version_root = skills_dir(store_root, &manifest.name, &manifest.version); + + // Idempotent re-install: rebuild the version dir from scratch so removed + // files don't linger. + if version_root.exists() { + std::fs::remove_dir_all(&version_root).map_err(|e| e.to_string())?; + } + + for blob in &docs.files { + verify::validate_document_path(&blob.path)?; + let plain = blob.decode_verified()?; + let dest = version_root.join(&blob.path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&dest, &plain).map_err(|e| format!("write {}: {e}", dest.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o444)); + } + } + Ok(version_root) +} + +/// Store layout for materialized skills: +/// `/skills///`. +pub fn skills_dir(store_root: &Path, name: &str, version: &str) -> PathBuf { + // `@ns/name` → `@ns__name`, mirroring `LocalRegistry::package_dir`. + let safe_name = name.replace('/', "__"); + store_root.join("skills").join(safe_name).join(version) +} + +fn sha256_hex(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(data); + crate::core::agent_identity::hex_encode(&h.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "lc-skills-{label}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write(dir: &Path, rel: &str, text: &str) { + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + + fn sample_dir(label: &str) -> PathBuf { + let dir = scratch(label); + write(&dir, "skills/review.md", "# Review checklist\n- tests\n"); + write(&dir, "skills/commit.md", "# Commit style\nimperative\n"); + write(&dir, "scripts/setup.sh", "#!/bin/sh\necho setup\n"); + write(&dir, ".hidden.md", "never packed"); + dir + } + + #[test] + fn builds_a_signed_verifying_skills_pack() { + let dir = sample_dir("build"); + let plan = build_skills_pack( + &dir, + "@das-tholo/lean-md-skills", + "1.0.0", + "Skills for lean-md", + Some("dasTholo"), + vec!["skills".into()], + ) + .expect("plan"); + + assert_eq!(plan.file_count, 3, "hidden file is skipped"); + let report = verify::verify_package_text(&plan.bundle_json); + assert!(report.valid(), "errors: {:?}", report.errors); + let doc: serde_json::Value = serde_json::from_str(&plan.bundle_json).unwrap(); + assert_eq!(doc["manifest"]["kind"].as_str(), Some("skills")); + + std::fs::remove_dir_all(&dir).ok(); + } + + /// #498 determinism guard: same input directory ⇒ byte-identical content + /// (and therefore an identical `content_hash`) across two builds. + #[test] + fn pack_content_is_deterministic() { + let dir = sample_dir("determinism"); + let a = build_skills_pack(&dir, "@t/s", "1.0.0", "d", None, vec![]).expect("a"); + let b = build_skills_pack(&dir, "@t/s", "1.0.0", "d", None, vec![]).expect("b"); + assert_eq!( + a.manifest.integrity.content_hash, + b.manifest.integrity.content_hash + ); + assert_eq!( + serde_json::to_string(&a.content).unwrap(), + serde_json::to_string(&b.content).unwrap() + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn tampered_blob_is_refused_at_verification_and_materialization() { + let dir = sample_dir("tamper"); + let plan = build_skills_pack(&dir, "@t/s", "1.0.0", "d", None, vec![]).expect("plan"); + + let mut content = plan.content.clone(); + let docs = content.documents.as_mut().unwrap(); + // Swap one body for another valid body — the per-blob plaintext hash + // must catch the substitution. + let other = docs.files[1].body.clone(); + docs.files[0].body = other; + + let mut errors = Vec::new(); + super::super::verify::validate_kind_coherence(&plan.manifest, &content) + .unwrap_err() + .iter() + .for_each(|e| errors.push(e.clone())); + assert!( + errors.iter().any(|e| e.contains("hash mismatch")), + "got: {errors:?}" + ); + + let store = scratch("tamper-store"); + let err = + materialize_documents(&store, &plan.manifest, content.documents.as_ref().unwrap()) + .unwrap_err(); + assert!(err.contains("hash mismatch"), "got: {err}"); + + std::fs::remove_dir_all(&dir).ok(); + std::fs::remove_dir_all(&store).ok(); + } + + #[test] + fn materializes_files_read_only_under_the_store() { + let dir = sample_dir("mat"); + let plan = build_skills_pack(&dir, "@t/s", "1.2.3", "d", None, vec![]).expect("plan"); + let store = scratch("mat-store"); + + let root = materialize_documents( + &store, + &plan.manifest, + plan.content.documents.as_ref().unwrap(), + ) + .expect("materialize"); + assert!(root.ends_with("skills/@t__s/1.2.3")); + let review = root.join("skills/review.md"); + assert_eq!( + std::fs::read_to_string(&review).unwrap(), + "# Review checklist\n- tests\n" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&review).unwrap().permissions().mode(); + assert_eq!(mode & 0o222, 0, "materialized skill files are read-only"); + } + + std::fs::remove_dir_all(&dir).ok(); + std::fs::remove_dir_all(&store).ok(); + } + + #[test] + fn traversal_paths_are_refused() { + for bad in ["../escape.md", "/abs.md", "a/../../b.md", "c:\\win.md"] { + assert!( + verify::validate_document_path(bad).is_err(), + "`{bad}` must be refused" + ); + } + assert!(verify::validate_document_path("skills/ok.md").is_ok()); + } + + /// Redaction-on-load (GH #727 acceptance): a secret inside a skill body + /// goes through the same redaction plane as every other text before it + /// reaches tool output. + #[test] + fn skill_bodies_pass_through_redaction() { + let dir = scratch("redact"); + write( + &dir, + "skills/creds.md", + "api key: sk-proj-abcdefghijklmnopqrstuvwxyz012345 do not share\n", + ); + let plan = build_skills_pack(&dir, "@t/r", "1.0.0", "d", None, vec![]).expect("plan"); + let blob = &plan.content.documents.as_ref().unwrap().files[0]; + let plain = String::from_utf8(blob.decode_verified().unwrap()).unwrap(); + + let redacted = crate::core::redaction::redact_text(&plain); + assert!( + !redacted.contains("sk-proj-abcdefghijklmnopqrstuvwxyz012345"), + "secret must not survive redaction: {redacted}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/rust/src/core/context_package/verify.rs b/rust/src/core/context_package/verify.rs index bfdec8359c..4301093ae8 100644 --- a/rust/src/core/context_package/verify.rs +++ b/rust/src/core/context_package/verify.rs @@ -188,13 +188,28 @@ pub fn validate_kind_coherence( } } }, - PackageKind::Context | PackageKind::Skills | PackageKind::Grammar => { + PackageKind::Skills => { + if content.addon.is_some() { + errors.push("content.addon payload requires kind=addon".into()); + } + match &content.documents { + None => errors.push("kind=skills requires a content.documents payload".into()), + Some(docs) => validate_documents(docs, &mut errors), + } + } + PackageKind::Context | PackageKind::Grammar => { if content.addon.is_some() { errors.push(format!( "content.addon payload requires kind=addon (manifest declares kind={})", manifest.kind.as_str() )); } + if content.documents.is_some() { + errors.push(format!( + "content.documents payload requires kind=skills (manifest declares kind={})", + manifest.kind.as_str() + )); + } } } if errors.is_empty() { @@ -204,6 +219,77 @@ pub fn validate_kind_coherence( } } +/// 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. +fn validate_documents(docs: &super::content::DocumentsContent, errors: &mut Vec) { + use super::content::{MAX_DOCUMENT_FILES, MAX_DOCUMENTS_TOTAL_BYTES}; + + if docs.files.is_empty() { + errors.push("kind=skills payload has no files".into()); + return; + } + if docs.files.len() > MAX_DOCUMENT_FILES { + errors.push(format!( + "skills payload has {} files (cap: {MAX_DOCUMENT_FILES})", + docs.files.len() + )); + return; + } + + let mut seen = std::collections::HashSet::new(); + let mut total: usize = 0; + for blob in &docs.files { + if let Err(e) = validate_document_path(&blob.path) { + errors.push(e); + continue; + } + if !seen.insert(blob.path.as_str()) { + errors.push(format!("duplicate document path `{}`", blob.path)); + continue; + } + if blob.sha256.len() != 64 || !blob.sha256.chars().all(|c| c.is_ascii_hexdigit()) { + errors.push(format!( + "`{}`: sha256 must be a 64-char hex string", + blob.path + )); + continue; + } + match blob.decode_verified() { + Ok(plain) => total += plain.len(), + Err(e) => errors.push(e), + } + } + if total > MAX_DOCUMENTS_TOTAL_BYTES { + errors.push(format!( + "skills payload decodes to {total} bytes (cap: {MAX_DOCUMENTS_TOTAL_BYTES})" + )); + } +} + +/// Path safety for document blobs: relative, `/`-separated, no traversal, no +/// absolute/drive/backslash forms — the materializer joins these under the +/// pack store and must never be able to escape it. +pub(crate) fn validate_document_path(path: &str) -> Result<(), String> { + if path.is_empty() || path.len() > 512 { + return Err(format!( + "invalid document path `{path}` (empty or too long)" + )); + } + if path.starts_with('/') || path.contains('\\') || path.contains(':') { + return Err(format!( + "invalid document path `{path}` (must be relative with `/` separators)" + )); + } + let has_bad_component = path + .split('/') + .any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with("..")); + if has_bad_component || path.chars().any(char::is_control) { + return Err(format!("invalid document path `{path}` (unsafe component)")); + } + Ok(()) +} + /// Outcome of one verification check. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CheckOutcome {