From 1cb0f03ea83b6b3bfd53319345d16d909ec0e3fe Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 01:42:12 +0200 Subject: [PATCH 01/13] feat: add strict capability pack parser --- src/capability_pack.rs | 335 ++++++++++++++++++ src/lib.rs | 1 + tests/capability_pack_contract.rs | 46 +++ .../capability-pack-v1/unknown-field.toml | 66 ++++ .../capability-pack-v1/unknown-version.toml | 65 ++++ .../capability-pack-v1/valid-minimal.toml | 65 ++++ 6 files changed, 578 insertions(+) create mode 100644 src/capability_pack.rs create mode 100644 tests/capability_pack_contract.rs create mode 100644 tests/fixtures/capability-pack-v1/unknown-field.toml create mode 100644 tests/fixtures/capability-pack-v1/unknown-version.toml create mode 100644 tests/fixtures/capability-pack-v1/valid-minimal.toml diff --git a/src/capability_pack.rs b/src/capability_pack.rs new file mode 100644 index 0000000..a323076 --- /dev/null +++ b/src/capability_pack.rs @@ -0,0 +1,335 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::config::{ + CacheConfig, CheckConfig, ConfigError, EnvironmentConfig, RuntimeConfig, StorageConfig, +}; +use crate::receipt::ReceiptError; + +pub const CAPABILITY_PACK_SCHEMA_VERSION: &str = "1.0"; +pub const MAX_CAPABILITY_PACK_BYTES: usize = 1_048_576; +const MAX_PACK_PROFILES: usize = 32; +const MAX_PACK_SOURCES: usize = 16; +const MAX_PROFILE_TOOLS: usize = 32; +const MAX_PROFILE_INPUTS: usize = 64; +const MAX_PROFILE_HOSTS: usize = 8; +const MAX_PROFILE_TARGETS: usize = 8; +const MAX_PROFILE_FEATURES: usize = 16; +const MAX_BLIND_SPOTS: usize = 32; +const MAX_LICENSE_BYTES: usize = 128; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CapabilityPackManifestV1 { + pub schema_version: String, + pub pack_id: String, + pub pack_version: String, + pub license: String, + pub description: String, + pub upstream_sources: Vec, + pub profiles: Vec, +} + +impl CapabilityPackManifestV1 { + pub fn parse(input: &str) -> Result { + if input.len() > MAX_CAPABILITY_PACK_BYTES { + return Err(CapabilityPackError::ManifestTooLarge { + actual: input.len(), + maximum: MAX_CAPABILITY_PACK_BYTES, + }); + } + toml::from_str(input).map_err(CapabilityPackError::Parse) + } + + pub fn load(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|source| CapabilityPackError::Io { + path: path.to_path_buf(), + source, + })?; + let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if size > MAX_CAPABILITY_PACK_BYTES { + return Err(CapabilityPackError::ManifestTooLarge { + actual: size, + maximum: MAX_CAPABILITY_PACK_BYTES, + }); + } + let input = fs::read_to_string(path).map_err(|source| CapabilityPackError::Io { + path: path.to_path_buf(), + source, + })?; + Self::parse(&input) + } + + pub fn validate(self) -> Result { + if self.schema_version != CAPABILITY_PACK_SCHEMA_VERSION { + return Err(CapabilityPackError::UnsupportedSchemaVersion( + self.schema_version, + )); + } + Err(CapabilityPackError::InvalidField("profiles")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityPackEnvelopeV1 { + pub pack_version: String, + pub manifest: CapabilityPackManifestV1, + #[allow(dead_code)] + pub manifest_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CapabilitySourceV1 { + pub id: String, + pub url: String, + pub digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CapabilityProfileConfigV1 { + pub id: String, + pub description: String, + pub evidence_class: CapabilityEvidenceClassV1, + pub pass_semantics: String, + pub known_blind_spots: Vec, + pub supported_hosts: Vec, + pub target_platforms: Vec, + pub required_runtime_features: Vec, + pub offline_preparation: OfflinePreparationV1, + pub tools: Vec, + #[serde(default)] + pub inputs: Vec, + pub runtime: RuntimeConfig, + #[serde(default)] + pub environment: EnvironmentConfig, + #[serde(default)] + pub caches: Vec, + pub storage: StorageConfig, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CapabilityToolV1 { + pub id: String, + pub version: String, + pub license: String, + pub url: String, + pub digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CapabilityInputProvenanceV1 { + pub id: String, + pub kind: CapabilityInputKindV1, + pub url: String, + pub digest: String, + #[serde(default)] + pub snapshot_created_at_utc: Option, + #[serde(default)] + pub max_age_seconds: Option, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityEvidenceClassV1 { + Deterministic, + ScheduleSensitive, + BoundedNondeterministic, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityInputKindV1 { + Rules, + TypeStubs, + Corpus, + AdvisoryDatabase, + VulnerabilityDatabase, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityHostPlatformV1 { + MacosAarch64, + LinuxArm64, + LinuxAmd64, + WindowsAmd64, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityTargetPlatformV1 { + LinuxArm64, + LinuxAmd64, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityRuntimeFeatureV1 { + LinuxUserland, + NoNetwork, + ReadOnlySource, + WritableCaches, + BoundedArtifacts, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum OfflinePreparationV1 { + None, + RequiredExternal, +} + +#[derive(Debug)] +pub enum CapabilityPackError { + Io { + path: PathBuf, + source: std::io::Error, + }, + Parse(toml::de::Error), + Json(serde_json::Error), + Receipt(ReceiptError), + Config(ConfigError), + UnsupportedSchemaVersion(String), + ManifestTooLarge { + actual: usize, + maximum: usize, + }, + InvalidField(&'static str), + TooManyItems { + field: &'static str, + actual: usize, + maximum: usize, + }, + DuplicateId { + field: &'static str, + id: String, + }, + DuplicateValue(&'static str), + ShellEntrypoint(String), + UnknownProfile(String), + PackDigestMismatch, +} + +impl fmt::Display for CapabilityPackError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => { + write!(formatter, "cannot read capability pack {path:?}: {source}") + } + Self::Parse(error) => write!(formatter, "invalid capability pack TOML: {error}"), + Self::Json(error) => write!( + formatter, + "capability pack JSON schema generation failed: {error}" + ), + Self::Receipt(error) => { + write!(formatter, "cannot build capability pack identity: {error}") + } + Self::Config(error) => { + write!( + formatter, + "invalid embedded config in capability pack: {error}" + ) + } + Self::UnsupportedSchemaVersion(version) => { + write!( + formatter, + "unsupported capability pack schema version: {version}" + ) + } + Self::ManifestTooLarge { actual, maximum } => { + write!( + formatter, + "capability pack is {actual} bytes; maximum is {maximum}" + ) + } + Self::InvalidField(field) => { + write!(formatter, "invalid capability pack field: {field}") + } + Self::TooManyItems { + field, + actual, + maximum, + } => { + write!( + formatter, + "capability pack has {actual} {field}; maximum is {maximum}" + ) + } + Self::DuplicateId { field, id } => write!(formatter, "duplicate {field}: {id}"), + Self::DuplicateValue(field) => write!(formatter, "duplicate value in {field}"), + Self::ShellEntrypoint(name) => write!(formatter, "invalid shell entrypoint: {name}"), + Self::UnknownProfile(name) => write!(formatter, "unknown profile referenced: {name}"), + Self::PackDigestMismatch => write!(formatter, "pack digest mismatch"), + } + } +} + +impl std::error::Error for CapabilityPackError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Parse(error) => Some(error), + Self::Json(error) => Some(error), + Self::Receipt(error) => Some(error), + Self::Config(error) => Some(error), + _ => None, + } + } +} + +impl From for CapabilityPackError { + fn from(error: ReceiptError) -> Self { + Self::Receipt(error) + } +} + +impl From for CapabilityPackError { + fn from(error: ConfigError) -> Self { + Self::Config(error) + } +} + +const _: () = { + let _ = MAX_PACK_PROFILES; + let _ = MAX_PACK_SOURCES; + let _ = MAX_PROFILE_TOOLS; + let _ = MAX_PROFILE_INPUTS; + let _ = MAX_PROFILE_HOSTS; + let _ = MAX_PROFILE_TARGETS; + let _ = MAX_PROFILE_FEATURES; + let _ = MAX_BLIND_SPOTS; + let _ = MAX_LICENSE_BYTES; +}; diff --git a/src/lib.rs b/src/lib.rs index 969179d..afbd215 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod agent_session; pub mod benchmark; pub mod cache; mod cache_payload; +pub mod capability_pack; pub mod config; pub mod durable_fs; pub mod github_actions; diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs new file mode 100644 index 0000000..bcc087f --- /dev/null +++ b/tests/capability_pack_contract.rs @@ -0,0 +1,46 @@ +// Copyright 2026 Marco Porcellato +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use commit_ci_preflight::capability_pack::{ + CAPABILITY_PACK_SCHEMA_VERSION, CapabilityPackError, CapabilityPackManifestV1, + MAX_CAPABILITY_PACK_BYTES, +}; + +const VALID: &str = include_str!("fixtures/capability-pack-v1/valid-minimal.toml"); +const UNKNOWN_FIELD: &str = include_str!("fixtures/capability-pack-v1/unknown-field.toml"); +const UNKNOWN_VERSION: &str = include_str!("fixtures/capability-pack-v1/unknown-version.toml"); + +#[test] +fn strict_manifest_parser_accepts_only_schema_1_0_toml() { + let manifest = CapabilityPackManifestV1::parse(VALID).expect("valid manifest"); + assert_eq!(manifest.schema_version, CAPABILITY_PACK_SCHEMA_VERSION); + assert_eq!(manifest.pack_id, "ccp.rust-minimal"); + assert!(CapabilityPackManifestV1::parse(UNKNOWN_FIELD).is_err()); + assert!(matches!( + CapabilityPackManifestV1::parse(UNKNOWN_VERSION) + .and_then(CapabilityPackManifestV1::validate), + Err(CapabilityPackError::UnsupportedSchemaVersion(version)) + if version == "2.0" + )); +} + +#[test] +fn manifest_parser_rejects_more_than_one_mebibyte_before_toml_decode() { + let oversized = "x".repeat(MAX_CAPABILITY_PACK_BYTES + 1); + assert!(matches!( + CapabilityPackManifestV1::parse(&oversized), + Err(CapabilityPackError::ManifestTooLarge { actual, maximum }) + if actual == MAX_CAPABILITY_PACK_BYTES + 1 && maximum == MAX_CAPABILITY_PACK_BYTES + )); +} diff --git a/tests/fixtures/capability-pack-v1/unknown-field.toml b/tests/fixtures/capability-pack-v1/unknown-field.toml new file mode 100644 index 0000000..94a2bf5 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/unknown-field.toml @@ -0,0 +1,66 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." +unexpected = true + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/unknown-version.toml b/tests/fixtures/capability-pack-v1/unknown-version.toml new file mode 100644 index 0000000..464a383 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/unknown-version.toml @@ -0,0 +1,65 @@ +schema_version = "2.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/valid-minimal.toml b/tests/fixtures/capability-pack-v1/valid-minimal.toml new file mode 100644 index 0000000..520d1dd --- /dev/null +++ b/tests/fixtures/capability-pack-v1/valid-minimal.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 From 324476e6d53c4b727ccbc41cff7884ec0c6d37f6 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 01:43:49 +0200 Subject: [PATCH 02/13] feat: restore capability source serialization --- src/capability_pack.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/capability_pack.rs b/src/capability_pack.rs index a323076..d6e3e94 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -96,7 +96,7 @@ pub struct CapabilityPackEnvelopeV1 { pub manifest_id: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CapabilitySourceV1 { pub id: String, From d4d6f5fb4f9612c1f4a0f1ada4235731e3a55380 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:03:12 +0200 Subject: [PATCH 03/13] feat: validate capability pack trust metadata --- src/capability_pack.rs | 513 ++++++++++++++- tests/capability_pack_contract.rs | 588 +++++++++++++++++- .../capability-pack-v1/dependency-cycle.toml | 74 +++ .../capability-pack-v1/invalid-image.toml | 65 ++ .../capability-pack-v1/invalid-license.toml | 65 ++ .../capability-pack-v1/invalid-path.toml | 65 ++ .../invalid-provenance.toml | 65 ++ .../capability-pack-v1/shell-entrypoint.toml | 65 ++ .../valid-minimal-reordered.toml | 65 ++ .../valid-minimal.canonical.json | 1 + .../capability-pack-v1/valid-minimal.toml | 2 +- 11 files changed, 1557 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/capability-pack-v1/dependency-cycle.toml create mode 100644 tests/fixtures/capability-pack-v1/invalid-image.toml create mode 100644 tests/fixtures/capability-pack-v1/invalid-license.toml create mode 100644 tests/fixtures/capability-pack-v1/invalid-path.toml create mode 100644 tests/fixtures/capability-pack-v1/invalid-provenance.toml create mode 100644 tests/fixtures/capability-pack-v1/shell-entrypoint.toml create mode 100644 tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml create mode 100644 tests/fixtures/capability-pack-v1/valid-minimal.canonical.json diff --git a/src/capability_pack.rs b/src/capability_pack.rs index d6e3e94..2d43c92 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -20,9 +21,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::config::{ - CacheConfig, CheckConfig, ConfigError, EnvironmentConfig, RuntimeConfig, StorageConfig, + CacheConfig, CheckConfig, ConfigError, ConfigV1, EnvironmentConfig, NormalizedCache, + NormalizedCheck, NormalizedEnvironment, NormalizedRuntime, NormalizedStorage, ReceiptConfig, + RuntimeConfig, RuntimeKind, StorageConfig, }; -use crate::receipt::ReceiptError; +use crate::receipt::{ReceiptError, canonical_digest, canonical_json}; pub const CAPABILITY_PACK_SCHEMA_VERSION: &str = "1.0"; pub const MAX_CAPABILITY_PACK_BYTES: usize = 1_048_576; @@ -84,16 +87,116 @@ impl CapabilityPackManifestV1 { self.schema_version, )); } - Err(CapabilityPackError::InvalidField("profiles")) + validate_identifier("pack_id", &self.pack_id)?; + validate_text("description", &self.description)?; + validate_license("license", &self.license)?; + validate_semver(&self.pack_version)?; + let upstream_sources = validate_sources(&self.upstream_sources)?; + if self.profiles.is_empty() { + return Err(CapabilityPackError::InvalidField("profiles")); + } + if self.profiles.len() > MAX_PACK_PROFILES { + return Err(CapabilityPackError::TooManyItems { + field: "profiles", + actual: self.profiles.len(), + maximum: MAX_PACK_PROFILES, + }); + } + let mut profiles = Vec::new(); + let mut ids = BTreeSet::new(); + let mut configs = BTreeMap::new(); + for profile in self.profiles { + if !ids.insert(profile.id.clone()) { + return Err(CapabilityPackError::DuplicateId { + field: "profiles.id", + id: profile.id, + }); + } + let (normalized, raw) = validate_profile(profile)?; + configs.insert(normalized.id.clone(), raw); + profiles.push(normalized); + } + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + let pack = NormalizedCapabilityPackV1 { + schema_version: self.schema_version, + pack_id: self.pack_id, + pack_version: self.pack_version, + license: self.license, + description: self.description, + upstream_sources, + profiles, + }; + let pack_digest = canonical_digest(&pack)?; + Ok(CapabilityPackEnvelopeV1 { + pack_digest, + pack, + profile_configs: configs, + }) } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CapabilityPackEnvelopeV1 { - pub pack_version: String, - pub manifest: CapabilityPackManifestV1, + pub pack_digest: String, + pub pack: NormalizedCapabilityPackV1, #[allow(dead_code)] - pub manifest_id: String, + #[serde(skip)] + profile_configs: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedCapabilityPackV1 { + pub schema_version: String, + pub pack_id: String, + pub pack_version: String, + pub license: String, + pub description: String, + pub upstream_sources: Vec, + pub profiles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct NormalizedCapabilityProfileV1 { + pub id: String, + pub description: String, + pub evidence_class: CapabilityEvidenceClassV1, + pub pass_semantics: String, + pub known_blind_spots: Vec, + pub supported_hosts: Vec, + pub target_platforms: Vec, + pub required_runtime_features: Vec, + pub offline_preparation: OfflinePreparationV1, + pub tools: Vec, + pub inputs: Vec, + pub runtime: NormalizedRuntime, + pub environment: NormalizedEnvironment, + pub caches: Vec, + pub storage: NormalizedStorage, + pub checks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ValidatedProfileConfigV1 { + evidence_class: CapabilityEvidenceClassV1, + runtime: RuntimeConfig, + environment: EnvironmentConfig, + caches: Vec, + storage: StorageConfig, + checks: Vec, +} + +impl CapabilityPackEnvelopeV1 { + pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { + if canonical_digest(&self.pack)? != self.pack_digest { + return Err(CapabilityPackError::PackDigestMismatch); + } + Ok(canonical_json(self)?) + } + pub fn inspection(&self) -> &NormalizedCapabilityPackV1 { + &self.pack + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -322,6 +425,402 @@ impl From for CapabilityPackError { } } +fn validate_identifier(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { + crate::config::validate_identifier(field, value) + .map_err(|_| CapabilityPackError::InvalidField(field)) +} + +fn validate_text(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { + if value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) { + Err(CapabilityPackError::InvalidField(field)) + } else { + Ok(()) + } +} + +fn validate_license(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { + if value.is_empty() + || value.len() > MAX_LICENSE_BYTES + || matches!(value, "NOASSERTION" | "NONE") + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) + { + Err(CapabilityPackError::InvalidField(field)) + } else { + Ok(()) + } +} + +fn validate_semver(value: &str) -> Result<(), CapabilityPackError> { + let components: Vec<_> = value.split('.').collect(); + if components.len() != 3 + || components.iter().any(|component| { + component.is_empty() + || !component.bytes().all(|byte| byte.is_ascii_digit()) + || (component.len() > 1 && component.starts_with('0')) + || component.parse::().is_err() + }) + { + Err(CapabilityPackError::InvalidField("pack_version")) + } else { + Ok(()) + } +} + +fn validate_digest(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { + if value.len() != 71 + || !value.starts_with("sha256:") + || !value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Err(CapabilityPackError::InvalidField(field)) + } else { + Ok(()) + } +} + +fn validate_url(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { + let authority = value + .strip_prefix("https://") + .and_then(|suffix| suffix.split('/').next()); + if value.len() > 4096 + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + || value.contains('#') + || authority.is_none_or(|authority| authority.is_empty() || authority.contains('@')) + { + Err(CapabilityPackError::InvalidField(field)) + } else { + Ok(()) + } +} + +fn validate_sources( + sources: &[CapabilitySourceV1], +) -> Result, CapabilityPackError> { + if sources.is_empty() { + return Err(CapabilityPackError::InvalidField("upstream_sources")); + } + if sources.len() > MAX_PACK_SOURCES { + return Err(CapabilityPackError::TooManyItems { + field: "upstream_sources", + actual: sources.len(), + maximum: MAX_PACK_SOURCES, + }); + } + let mut by_id = BTreeMap::new(); + for source in sources { + validate_identifier("upstream_sources.id", &source.id)?; + validate_url("upstream_sources.url", &source.url)?; + validate_digest("upstream_sources.digest", &source.digest)?; + if by_id.insert(source.id.clone(), source.clone()).is_some() { + return Err(CapabilityPackError::DuplicateId { + field: "upstream_sources.id", + id: source.id.clone(), + }); + } + } + Ok(by_id.into_values().collect()) +} + +fn unique_sorted( + field: &'static str, + values: &[T], +) -> Result, CapabilityPackError> { + let mut unique = BTreeSet::new(); + for value in values { + if !unique.insert(value.clone()) { + return Err(CapabilityPackError::DuplicateValue(field)); + } + } + Ok(unique.into_iter().collect()) +} + +fn validate_tools( + tools: &[CapabilityToolV1], +) -> Result, CapabilityPackError> { + if tools.is_empty() { + return Err(CapabilityPackError::InvalidField("profiles.tools")); + } + if tools.len() > MAX_PROFILE_TOOLS { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.tools", + actual: tools.len(), + maximum: MAX_PROFILE_TOOLS, + }); + } + let mut by_id = BTreeMap::new(); + for tool in tools { + validate_identifier("profiles.tools.id", &tool.id)?; + validate_text("profiles.tools.version", &tool.version)?; + validate_license("profiles.tools.license", &tool.license)?; + validate_url("profiles.tools.url", &tool.url)?; + validate_digest("profiles.tools.digest", &tool.digest)?; + if by_id.insert(tool.id.clone(), tool.clone()).is_some() { + return Err(CapabilityPackError::DuplicateId { + field: "profiles.tools.id", + id: tool.id.clone(), + }); + } + } + Ok(by_id.into_values().collect()) +} + +fn validate_inputs( + inputs: &[CapabilityInputProvenanceV1], +) -> Result, CapabilityPackError> { + if inputs.len() > MAX_PROFILE_INPUTS { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.inputs", + actual: inputs.len(), + maximum: MAX_PROFILE_INPUTS, + }); + } + let mut by_id = BTreeMap::new(); + for input in inputs { + validate_identifier("profiles.inputs.id", &input.id)?; + validate_url("profiles.inputs.url", &input.url)?; + validate_digest("profiles.inputs.digest", &input.digest)?; + let database = matches!( + input.kind, + CapabilityInputKindV1::AdvisoryDatabase | CapabilityInputKindV1::VulnerabilityDatabase + ); + if database { + let (created, max_age_seconds) = + match (&input.snapshot_created_at_utc, input.max_age_seconds) { + (Some(created), Some(max_age_seconds)) => (created, max_age_seconds), + _ => { + return Err(CapabilityPackError::InvalidField( + "profiles.inputs.freshness", + )); + } + }; + if crate::verify::parse_utc_seconds(created).is_none() { + return Err(CapabilityPackError::InvalidField( + "profiles.inputs.snapshot_created_at_utc", + )); + } + if !(1..=31_536_000).contains(&max_age_seconds) { + return Err(CapabilityPackError::InvalidField( + "profiles.inputs.max_age_seconds", + )); + } + } else if input.snapshot_created_at_utc.is_some() || input.max_age_seconds.is_some() { + return Err(CapabilityPackError::InvalidField( + "profiles.inputs.freshness", + )); + } + if by_id.insert(input.id.clone(), input.clone()).is_some() { + return Err(CapabilityPackError::DuplicateId { + field: "profiles.inputs.id", + id: input.id.clone(), + }); + } + } + Ok(by_id.into_values().collect()) +} + +fn shell_entrypoint(argv: &[String]) -> bool { + const SHELLS: &[&str] = &[ + "sh", + "bash", + "dash", + "zsh", + "ksh", + "fish", + "csh", + "tcsh", + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + ]; + let Some(entrypoint) = argv.first() else { + return false; + }; + let basename = entrypoint + .rsplit('/') + .next() + .unwrap_or(entrypoint) + .to_ascii_lowercase(); + if SHELLS.contains(&basename.as_str()) { + return true; + } + if !matches!(basename.as_str(), "env") { + return false; + } + argv.iter() + .skip(1) + .find(|argument| !argument.starts_with('-')) + .is_some_and(|argument| { + let name = argument + .rsplit('/') + .next() + .unwrap_or(argument) + .to_ascii_lowercase(); + SHELLS.contains(&name.as_str()) + }) +} + +fn validate_profile( + p: CapabilityProfileConfigV1, +) -> Result<(NormalizedCapabilityProfileV1, ValidatedProfileConfigV1), CapabilityPackError> { + validate_identifier("profiles.id", &p.id)?; + validate_text("profiles.description", &p.description)?; + validate_text("profiles.pass_semantics", &p.pass_semantics)?; + if p.supported_hosts.is_empty() { + return Err(CapabilityPackError::InvalidField( + "profiles.supported_hosts", + )); + } + if p.supported_hosts.len() > MAX_PROFILE_HOSTS { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.supported_hosts", + actual: p.supported_hosts.len(), + maximum: MAX_PROFILE_HOSTS, + }); + } + if p.target_platforms.is_empty() { + return Err(CapabilityPackError::InvalidField( + "profiles.target_platforms", + )); + } + if p.target_platforms.len() > MAX_PROFILE_TARGETS { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.target_platforms", + actual: p.target_platforms.len(), + maximum: MAX_PROFILE_TARGETS, + }); + } + if p.required_runtime_features.is_empty() { + return Err(CapabilityPackError::InvalidField( + "profiles.required_runtime_features", + )); + } + if p.required_runtime_features.len() > MAX_PROFILE_FEATURES { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.required_runtime_features", + actual: p.required_runtime_features.len(), + maximum: MAX_PROFILE_FEATURES, + }); + } + if p.known_blind_spots.is_empty() { + return Err(CapabilityPackError::InvalidField( + "profiles.known_blind_spots", + )); + } + if p.known_blind_spots.len() > MAX_BLIND_SPOTS { + return Err(CapabilityPackError::TooManyItems { + field: "profiles.known_blind_spots", + actual: p.known_blind_spots.len(), + maximum: MAX_BLIND_SPOTS, + }); + } + + let supported_hosts = unique_sorted("profiles.supported_hosts", &p.supported_hosts)?; + let target_platforms = unique_sorted("profiles.target_platforms", &p.target_platforms)?; + let required_runtime_features = unique_sorted( + "profiles.required_runtime_features", + &p.required_runtime_features, + )?; + let known_blind_spots = { + for blind_spot in &p.known_blind_spots { + validate_text("profiles.known_blind_spots", blind_spot)?; + } + unique_sorted("profiles.known_blind_spots", &p.known_blind_spots)? + }; + for required in [ + CapabilityRuntimeFeatureV1::NoNetwork, + CapabilityRuntimeFeatureV1::ReadOnlySource, + CapabilityRuntimeFeatureV1::LinuxUserland, + ] { + if !required_runtime_features.contains(&required) { + return Err(CapabilityPackError::InvalidField( + "profiles.required_runtime_features", + )); + } + } + let artifacts_declared = p.checks.iter().any(|check| !check.artifacts.is_empty()); + if required_runtime_features.contains(&CapabilityRuntimeFeatureV1::WritableCaches) + == p.caches.is_empty() + || required_runtime_features.contains(&CapabilityRuntimeFeatureV1::BoundedArtifacts) + != artifacts_declared + { + return Err(CapabilityPackError::InvalidField( + "profiles.required_runtime_features", + )); + } + if p.runtime.kind != RuntimeKind::DockerCompatible { + return Err(CapabilityPackError::InvalidField("profiles.runtime.kind")); + } + if p.runtime.network { + return Err(CapabilityPackError::InvalidField( + "profiles.runtime.network", + )); + } + for check in &p.checks { + if shell_entrypoint(&check.argv) { + return Err(CapabilityPackError::ShellEntrypoint(check.id.clone())); + } + } + + let tools = validate_tools(&p.tools)?; + let inputs = validate_inputs(&p.inputs)?; + let raw = ValidatedProfileConfigV1 { + evidence_class: p.evidence_class, + runtime: p.runtime.clone(), + environment: p.environment.clone(), + caches: p.caches.clone(), + storage: p.storage.clone(), + checks: p.checks.clone(), + }; + let plan = ConfigV1 { + schema_version: "1.3".to_owned(), + project: "capability-pack/validation".to_owned(), + runtime: raw.runtime.clone(), + receipt: ReceiptConfig { + output: ".ccp/capability-pack-validation.json".to_owned(), + freshness_seconds: 86_400, + }, + environment: raw.environment.clone(), + caches: raw.caches.clone(), + storage: Some(raw.storage.clone()), + checks: raw.checks.clone(), + } + .into_plan()?; + let storage = plan + .plan + .storage + .clone() + .ok_or(CapabilityPackError::InvalidField("profiles.storage"))?; + Ok(( + NormalizedCapabilityProfileV1 { + id: p.id, + description: p.description, + evidence_class: p.evidence_class, + pass_semantics: p.pass_semantics, + known_blind_spots, + supported_hosts, + target_platforms, + required_runtime_features, + offline_preparation: p.offline_preparation, + tools, + inputs, + runtime: plan.plan.runtime, + environment: plan.plan.environment, + caches: plan.plan.caches, + storage, + checks: plan.plan.checks, + }, + raw, + )) +} + const _: () = { let _ = MAX_PACK_PROFILES; let _ = MAX_PACK_SOURCES; diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index bcc087f..d6ef26b 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -13,13 +13,65 @@ // limitations under the License. use commit_ci_preflight::capability_pack::{ - CAPABILITY_PACK_SCHEMA_VERSION, CapabilityPackError, CapabilityPackManifestV1, + CAPABILITY_PACK_SCHEMA_VERSION, CapabilityInputKindV1, CapabilityPackEnvelopeV1, + CapabilityPackError, CapabilityPackManifestV1, CapabilityRuntimeFeatureV1, MAX_CAPABILITY_PACK_BYTES, }; +use commit_ci_preflight::config::{ConfigError, RuntimeKind}; const VALID: &str = include_str!("fixtures/capability-pack-v1/valid-minimal.toml"); const UNKNOWN_FIELD: &str = include_str!("fixtures/capability-pack-v1/unknown-field.toml"); const UNKNOWN_VERSION: &str = include_str!("fixtures/capability-pack-v1/unknown-version.toml"); +const INVALID_IMAGE: &str = include_str!("fixtures/capability-pack-v1/invalid-image.toml"); +const INVALID_LICENSE: &str = include_str!("fixtures/capability-pack-v1/invalid-license.toml"); +const INVALID_PROVENANCE: &str = + include_str!("fixtures/capability-pack-v1/invalid-provenance.toml"); +const INVALID_PATH: &str = include_str!("fixtures/capability-pack-v1/invalid-path.toml"); +const SHELL_ENTRYPOINT: &str = include_str!("fixtures/capability-pack-v1/shell-entrypoint.toml"); +const DEPENDENCY_CYCLE: &str = include_str!("fixtures/capability-pack-v1/dependency-cycle.toml"); +const REORDERED_VALID: &str = + include_str!("fixtures/capability-pack-v1/valid-minimal-reordered.toml"); +const PINNED_CANONICAL: &[u8] = + include_bytes!("fixtures/capability-pack-v1/valid-minimal.canonical.json"); + +fn validate_fixture(source: &str) -> Result { + CapabilityPackManifestV1::parse(source)?.validate() +} + +fn valid_manifest() -> CapabilityPackManifestV1 { + CapabilityPackManifestV1::parse(VALID).expect("valid input model") +} + +fn assert_invalid_field( + mutate: impl FnOnce(&mut CapabilityPackManifestV1), + expected: &'static str, +) { + let mut manifest = valid_manifest(); + mutate(&mut manifest); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::InvalidField(field)) if field == expected + )); +} + +fn assert_invalid_freshness(created: Option<&str>, max_age_seconds: Option) { + let mut manifest = valid_manifest(); + let input = manifest.profiles[0] + .inputs + .first_mut() + .expect("valid fixture database input"); + input.snapshot_created_at_utc = created.map(str::to_owned); + input.max_age_seconds = max_age_seconds; + let expected = if created.is_some() && max_age_seconds.is_some() { + "profiles.inputs.snapshot_created_at_utc" + } else { + "profiles.inputs.freshness" + }; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::InvalidField(field)) if field == expected + )); +} #[test] fn strict_manifest_parser_accepts_only_schema_1_0_toml() { @@ -30,8 +82,7 @@ fn strict_manifest_parser_accepts_only_schema_1_0_toml() { assert!(matches!( CapabilityPackManifestV1::parse(UNKNOWN_VERSION) .and_then(CapabilityPackManifestV1::validate), - Err(CapabilityPackError::UnsupportedSchemaVersion(version)) - if version == "2.0" + Err(CapabilityPackError::UnsupportedSchemaVersion(version)) if version == "2.0" )); } @@ -44,3 +95,534 @@ fn manifest_parser_rejects_more_than_one_mebibyte_before_toml_decode() { if actual == MAX_CAPABILITY_PACK_BYTES + 1 && maximum == MAX_CAPABILITY_PACK_BYTES )); } + +#[test] +fn validator_rejects_untrusted_metadata_and_unsafe_execution_shape() { + assert!(matches!( + validate_fixture(INVALID_LICENSE), + Err(CapabilityPackError::InvalidField("license")) + )); + assert!(matches!( + validate_fixture(INVALID_PROVENANCE), + Err(CapabilityPackError::InvalidField("profiles.tools.digest")) + )); + assert!( + matches!(validate_fixture(SHELL_ENTRYPOINT), Err(CapabilityPackError::ShellEntrypoint(id)) if id == "clippy") + ); + assert!(matches!( + validate_fixture(INVALID_IMAGE), + Err(CapabilityPackError::Config(ConfigError::InvalidField( + "runtime.image" + ))) + )); + assert!(matches!( + validate_fixture(INVALID_PATH), + Err(CapabilityPackError::Config(ConfigError::InvalidField( + "cache.mount_path" + ))) + )); + assert!(matches!( + validate_fixture(DEPENDENCY_CYCLE), + Err(CapabilityPackError::Config(ConfigError::DependencyCycle(_))) + )); +} + +#[test] +fn validator_rejects_shells_invoked_through_env() { + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].argv = vec![ + "/usr/bin/env".to_owned(), + "-i".to_owned(), + "bash".to_owned(), + "-c".to_owned(), + "cargo clippy".to_owned(), + ]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::ShellEntrypoint(id)) if id == "clippy" + )); +} + +#[test] +fn normalized_pack_is_order_independent_and_matches_pinned_canonical_bytes() { + let first = validate_fixture(VALID).expect("first pack"); + let reordered = validate_fixture(REORDERED_VALID).expect("reordered pack"); + assert_eq!(first.pack_digest, reordered.pack_digest); + assert_eq!(first.inspection(), reordered.inspection()); + assert_eq!( + first.canonical_bytes().expect("canonical bytes"), + PINNED_CANONICAL + ); +} + +#[test] +fn freshness_metadata_is_required_as_an_atomic_pair() { + assert_invalid_freshness(None, Some(86_400)); + assert_invalid_freshness(Some("2026-08-30T00:00:00Z"), None); + assert_invalid_freshness(Some("2026-02-30T00:00:00Z"), Some(86_400)); +} + +#[test] +fn validator_enforces_pack_and_profile_collection_bounds() { + assert_invalid_field(|m| m.profiles.clear(), "profiles"); + assert_invalid_field(|m| m.upstream_sources.clear(), "upstream_sources"); + assert_invalid_field(|m| m.profiles[0].tools.clear(), "profiles.tools"); + assert_invalid_field( + |m| m.profiles[0].supported_hosts.clear(), + "profiles.supported_hosts", + ); + assert_invalid_field( + |m| m.profiles[0].target_platforms.clear(), + "profiles.target_platforms", + ); + assert_invalid_field( + |m| m.profiles[0].required_runtime_features.clear(), + "profiles.required_runtime_features", + ); + assert_invalid_field( + |m| m.profiles[0].known_blind_spots.clear(), + "profiles.known_blind_spots", + ); + + let mut manifest = valid_manifest(); + let profile = manifest.profiles[0].clone(); + manifest.profiles = (0..33) + .map(|index| { + let mut value = profile.clone(); + value.id = format!("profile-{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles", + actual: 33, + maximum: 32 + }) + )); + let mut manifest = valid_manifest(); + let source = manifest.upstream_sources[0].clone(); + manifest.upstream_sources = (0..17) + .map(|index| { + let mut value = source.clone(); + value.id = format!("source-{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "upstream_sources", + actual: 17, + maximum: 16 + }) + )); + let mut manifest = valid_manifest(); + let tool = manifest.profiles[0].tools[0].clone(); + manifest.profiles[0].tools = (0..33) + .map(|index| { + let mut value = tool.clone(); + value.id = format!("tool-{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.tools", + actual: 33, + maximum: 32 + }) + )); + let mut manifest = valid_manifest(); + let input = manifest.profiles[0].inputs[0].clone(); + manifest.profiles[0].inputs = (0..65) + .map(|index| { + let mut value = input.clone(); + value.id = format!("input-{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.inputs", + actual: 65, + maximum: 64 + }) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].supported_hosts = vec![manifest.profiles[0].supported_hosts[0]; 9]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.supported_hosts", + actual: 9, + maximum: 8 + }) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].target_platforms = vec![manifest.profiles[0].target_platforms[0]; 9]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.target_platforms", + actual: 9, + maximum: 8 + }) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].required_runtime_features = + vec![CapabilityRuntimeFeatureV1::NoNetwork; 17]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.required_runtime_features", + actual: 17, + maximum: 16 + }) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].known_blind_spots = (0..33).map(|i| format!("blind-{i}")).collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::TooManyItems { + field: "profiles.known_blind_spots", + actual: 33, + maximum: 32 + }) + )); +} + +#[test] +fn validator_reuses_embedded_config_collection_checks() { + let mut manifest = valid_manifest(); + manifest.profiles[0].checks.clear(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::NoChecks)) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].required = false; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::NoRequiredChecks)) + )); + let mut manifest = valid_manifest(); + let check = manifest.profiles[0].checks[0].clone(); + manifest.profiles[0].checks = (0..129) + .map(|index| { + let mut value = check.clone(); + value.id = format!("check-{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::TooManyItems { + field: "checks", + actual: 129, + maximum: 128 + })) + )); + let mut manifest = valid_manifest(); + let cache = manifest.profiles[0].caches[0].clone(); + manifest.profiles[0].caches = (0..33) + .map(|index| { + let mut value = cache.clone(); + value.id = format!("cache-{index}"); + value.mount_path = format!(".cache/{index}"); + value + }) + .collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::TooManyItems { + field: "caches", + actual: 33, + maximum: 32 + })) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].argv = vec!["command".to_owned(); 65]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::InvalidField( + "check.argv" + ))) + )); +} + +#[test] +fn validator_rejects_malformed_metadata_and_duplicates() { + assert_invalid_field(|m| m.description = "x".repeat(4097), "description"); + assert_invalid_field(|m| m.pack_id = "bad id".to_owned(), "pack_id"); + assert_invalid_field(|m| m.profiles[0].id = "bad id".to_owned(), "profiles.id"); + assert_invalid_field( + |m| m.upstream_sources[0].id = "bad id".to_owned(), + "upstream_sources.id", + ); + assert_invalid_field( + |m| m.profiles[0].tools[0].id = "bad id".to_owned(), + "profiles.tools.id", + ); + assert_invalid_field( + |m| m.profiles[0].inputs[0].id = "bad id".to_owned(), + "profiles.inputs.id", + ); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].clone(); + manifest.profiles.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateId { + field: "profiles.id", + .. + }) + )); + let mut manifest = valid_manifest(); + let value = manifest.upstream_sources[0].clone(); + manifest.upstream_sources.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateId { + field: "upstream_sources.id", + .. + }) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].tools[0].clone(); + manifest.profiles[0].tools.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateId { + field: "profiles.tools.id", + .. + }) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].inputs[0].clone(); + manifest.profiles[0].inputs.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateId { + field: "profiles.inputs.id", + .. + }) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].checks[0].clone(); + manifest.profiles[0].checks.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::DuplicateId { + field: "check.id", + .. + })) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].caches[0].clone(); + manifest.profiles[0].caches.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::DuplicateId { + field: "cache.id", + .. + })) + )); +} + +#[test] +fn validator_rejects_duplicate_values_and_runtime_feature_contract_breaks() { + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].supported_hosts[0]; + manifest.profiles[0].supported_hosts.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateValue( + "profiles.supported_hosts" + )) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].target_platforms[0]; + manifest.profiles[0].target_platforms.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateValue( + "profiles.target_platforms" + )) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0] + .required_runtime_features + .push(CapabilityRuntimeFeatureV1::NoNetwork); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateValue( + "profiles.required_runtime_features" + )) + )); + let mut manifest = valid_manifest(); + let value = manifest.profiles[0].known_blind_spots[0].clone(); + manifest.profiles[0].known_blind_spots.push(value); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::DuplicateValue( + "profiles.known_blind_spots" + )) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].depends_on = vec!["clippy".to_owned(), "clippy".to_owned()]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::DuplicateValue( + "check.depends_on" + ))) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].artifacts = vec!["artifact".to_owned(), "artifact".to_owned()]; + manifest.profiles[0] + .required_runtime_features + .push(CapabilityRuntimeFeatureV1::BoundedArtifacts); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config(ConfigError::DuplicateValue( + "check.artifacts" + ))) + )); + assert_invalid_field( + |m| { + m.profiles[0].required_runtime_features.pop(); + }, + "profiles.required_runtime_features", + ); + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].artifacts = vec!["artifact".to_owned()]; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::InvalidField( + "profiles.required_runtime_features" + )) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].caches.clear(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::InvalidField( + "profiles.required_runtime_features" + )) + )); + assert_invalid_field( + |m| m.profiles[0].runtime.kind = RuntimeKind::Host, + "profiles.runtime.kind", + ); + assert_invalid_field( + |m| m.profiles[0].runtime.network = true, + "profiles.runtime.network", + ); +} + +#[test] +fn validator_enforces_value_syntax_and_input_freshness() { + for value in ["v1.0.0", "1.0", "01.0.0", "1.0.0-alpha"] { + assert_invalid_field(|m| m.pack_version = value.to_owned(), "pack_version"); + } + assert_invalid_field( + |m| m.profiles[0].tools[0].version.clear(), + "profiles.tools.version", + ); + assert_invalid_field( + |m| m.profiles[0].tools[0].version = "bad\u{0000}".to_owned(), + "profiles.tools.version", + ); + assert_invalid_field( + |m| m.profiles[0].tools[0].version = "x".repeat(4097), + "profiles.tools.version", + ); + for value in [ + "http://example.com", + "https://user@example.com", + "https://example.com/#fragment", + "https://example.com/space here", + "https://example.com/\u{0000}", + ] { + assert_invalid_field( + |m| m.upstream_sources[0].url = value.to_owned(), + "upstream_sources.url", + ); + } + assert_invalid_field( + |m| m.profiles[0].tools[0].url = "http://example.com".to_owned(), + "profiles.tools.url", + ); + assert_invalid_field( + |m| m.profiles[0].inputs[0].url = "http://example.com".to_owned(), + "profiles.inputs.url", + ); + for value in ["NOASSERTION", "NONE", "Apache-2.0 OR MIT"] { + assert_invalid_field(|m| m.license = value.to_owned(), "license"); + } + assert_invalid_field(|m| m.license = "x".repeat(129), "license"); + assert_invalid_field( + |m| m.profiles[0].tools[0].license = "Apache-2.0 OR MIT".to_owned(), + "profiles.tools.license", + ); + assert_invalid_field(|m| m.description.clear(), "description"); + assert_invalid_field( + |m| m.profiles[0].description.clear(), + "profiles.description", + ); + assert_invalid_field( + |m| m.profiles[0].pass_semantics.clear(), + "profiles.pass_semantics", + ); + assert_invalid_freshness(None, Some(86_400)); + assert_invalid_field( + |m| m.profiles[0].inputs[0].max_age_seconds = Some(0), + "profiles.inputs.max_age_seconds", + ); + let mut manifest = valid_manifest(); + manifest.profiles[0].inputs[0].kind = CapabilityInputKindV1::Rules; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::InvalidField( + "profiles.inputs.freshness" + )) + )); +} + +#[test] +fn parser_requires_explicit_storage_and_schema_13_runtime_policy() { + let without_storage = VALID.replacen("[profiles.storage]\nmin_free_bytes = 1048576\nreceipt_journal_reserve_bytes = 4096\nmax_cache_growth_bytes = 1048576\n\n", "", 1); + assert!(matches!( + CapabilityPackManifestV1::parse(&without_storage), + Err(CapabilityPackError::Parse(_)) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].runtime.pull_policy = None; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config( + ConfigError::MissingRuntimeCapabilityPolicy + )) + )); + let mut manifest = valid_manifest(); + manifest.profiles[0].runtime.swap_mode = None; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::Config( + ConfigError::MissingRuntimeCapabilityPolicy + )) + )); +} + +#[test] +fn canonical_bytes_rejects_tampered_digest() { + let mut envelope = validate_fixture(VALID).expect("valid envelope"); + envelope.pack_digest = "sha256:bad".to_owned(); + assert!(matches!( + envelope.canonical_bytes(), + Err(CapabilityPackError::PackDigestMismatch) + )); +} diff --git a/tests/fixtures/capability-pack-v1/dependency-cycle.toml b/tests/fixtures/capability-pack-v1/dependency-cycle.toml new file mode 100644 index 0000000..c607f90 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/dependency-cycle.toml @@ -0,0 +1,74 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy"] +working_directory = "." +timeout_seconds = 1800 +depends_on = ["tests"] + +[[profiles.checks]] +id = "tests" +required = true +argv = ["cargo", "test"] +working_directory = "." +timeout_seconds = 1800 +depends_on = ["clippy"] diff --git a/tests/fixtures/capability-pack-v1/invalid-image.toml b/tests/fixtures/capability-pack-v1/invalid-image.toml new file mode 100644 index 0000000..5290eea --- /dev/null +++ b/tests/fixtures/capability-pack-v1/invalid-image.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/invalid-license.toml b/tests/fixtures/capability-pack-v1/invalid-license.toml new file mode 100644 index 0000000..8d4bf16 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/invalid-license.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0 OR MIT" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/invalid-path.toml b/tests/fixtures/capability-pack-v1/invalid-path.toml new file mode 100644 index 0000000..4164f1c --- /dev/null +++ b/tests/fixtures/capability-pack-v1/invalid-path.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = "../cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/invalid-provenance.toml b/tests/fixtures/capability-pack-v1/invalid-provenance.toml new file mode 100644 index 0000000..30835fd --- /dev/null +++ b/tests/fixtures/capability-pack-v1/invalid-provenance.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbB" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/shell-entrypoint.toml b/tests/fixtures/capability-pack-v1/shell-entrypoint.toml new file mode 100644 index 0000000..5ec17e6 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/shell-entrypoint.toml @@ -0,0 +1,65 @@ +schema_version = "1.0" +pack_id = "ccp.rust-minimal" +pack_version = "1.0.0" +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." + +[[upstream_sources]] +id = "pack-source" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +[[profiles]] +id = "strict-clippy" +description = "Compile all targets with warnings denied." +evidence_class = "deterministic" +pass_semantics = "The exact pinned command exits zero." +known_blind_spots = ["Does not prove dynamic behavior."] +supported_hosts = ["macos-aarch64"] +target_platforms = ["linux-arm64"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] +offline_preparation = "none" + +[[profiles.tools]] +id = "clippy" +version = "1.87.0" +license = "Apache-2.0" +url = "https://github.com/rust-lang/rust" +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +[[profiles.inputs]] +id = "rustsec-db" +kind = "advisory-database" +url = "https://github.com/RustSec/advisory-db" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +max_age_seconds = 604800 + +[profiles.runtime] +kind = "docker_compatible" +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +cpu_count = 2 +memory_mib = 2048 +pids_limit = 256 +network = false +pull_policy = "never" +swap_mode = "disabled" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +min_free_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +max_cache_growth_bytes = 1048576 + +[[profiles.caches]] +id = "cargo" +mount_path = ".cache/cargo" + +[[profiles.checks]] +id = "clippy" +required = true +argv = ["/bin/sh", "-c", "cargo clippy"] +working_directory = "." +timeout_seconds = 1800 diff --git a/tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml b/tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml new file mode 100644 index 0000000..8dffd9d --- /dev/null +++ b/tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml @@ -0,0 +1,65 @@ +license = "Apache-2.0" +description = "One inert profile used to prove the Capability Pack contract." +pack_version = "1.0.0" +pack_id = "ccp.rust-minimal" +schema_version = "1.0" + +[[upstream_sources]] +digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +url = "https://github.com/MarcoPorcellato/commit-ci-preflight" +id = "pack-source" + +[[profiles]] +offline_preparation = "none" +required_runtime_features = ["writable-caches", "read-only-source", "no-network", "linux-userland"] +target_platforms = ["linux-arm64"] +supported_hosts = ["macos-aarch64"] +known_blind_spots = ["Does not prove dynamic behavior."] +pass_semantics = "The exact pinned command exits zero." +evidence_class = "deterministic" +description = "Compile all targets with warnings denied." +id = "strict-clippy" + +[[profiles.tools]] +digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +url = "https://github.com/rust-lang/rust" +license = "Apache-2.0" +version = "1.87.0" +id = "clippy" + +[[profiles.inputs]] +max_age_seconds = 604800 +snapshot_created_at_utc = "2026-08-30T00:00:00Z" +digest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" +url = "https://github.com/RustSec/advisory-db" +kind = "advisory-database" +id = "rustsec-db" + +[profiles.runtime] +swap_mode = "disabled" +pull_policy = "never" +network = false +pids_limit = 256 +memory_mib = 2048 +cpu_count = 2 +image = "ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +kind = "docker_compatible" + +[profiles.environment] +fixed = { CARGO_NET_OFFLINE = "true" } + +[profiles.storage] +max_cache_growth_bytes = 1048576 +receipt_journal_reserve_bytes = 4096 +min_free_bytes = 1048576 + +[[profiles.caches]] +mount_path = ".cache/cargo" +id = "cargo" + +[[profiles.checks]] +timeout_seconds = 1800 +working_directory = "." +argv = ["cargo", "clippy", "--locked", "--offline", "--all-targets", "--all-features", "--", "-D", "warnings"] +required = true +id = "clippy" diff --git a/tests/fixtures/capability-pack-v1/valid-minimal.canonical.json b/tests/fixtures/capability-pack-v1/valid-minimal.canonical.json new file mode 100644 index 0000000..3660c32 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/valid-minimal.canonical.json @@ -0,0 +1 @@ +{"pack":{"description":"One inert profile used to prove the Capability Pack contract.","license":"Apache-2.0","pack_id":"ccp.rust-minimal","pack_version":"1.0.0","profiles":[{"caches":[{"id":"cargo","mount_path":".cache/cargo"}],"checks":[{"argv":["cargo","clippy","--locked","--offline","--all-targets","--all-features","--","-D","warnings"],"artifacts":[],"depends_on":[],"id":"clippy","required":true,"timeout_seconds":1800,"working_directory":"."}],"description":"Compile all targets with warnings denied.","environment":{"fixed":[{"name":"CARGO_NET_OFFLINE","value_digest":"sha256:18d10c7d2b4b04aaf04254d1ae5d655a5dc0407cbcdd5a8c3986e985370f36ee"}],"inherit":[],"remote_secret_only":[],"runtime_internal":[]},"evidence_class":"deterministic","id":"strict-clippy","inputs":[{"digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","id":"rustsec-db","kind":"advisory-database","max_age_seconds":604800,"snapshot_created_at_utc":"2026-08-30T00:00:00Z","url":"https://github.com/RustSec/advisory-db"}],"known_blind_spots":["Does not prove dynamic behavior."],"offline_preparation":"none","pass_semantics":"The exact pinned command exits zero.","required_runtime_features":["linux-userland","no-network","read-only-source","writable-caches"],"runtime":{"cpu_count":2,"image":"ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","kind":"docker_compatible","memory_mib":2048,"network":false,"pids_limit":256,"pull_policy":"never","swap_mode":"disabled"},"storage":{"max_artifact_bytes":0,"max_cache_growth_bytes":1048576,"min_free_bytes":1048576,"receipt_journal_reserve_bytes":4096},"supported_hosts":["macos-aarch64"],"target_platforms":["linux-arm64"],"tools":[{"digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"clippy","license":"Apache-2.0","url":"https://github.com/rust-lang/rust","version":"1.87.0"}]}],"schema_version":"1.0","upstream_sources":[{"digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","id":"pack-source","url":"https://github.com/MarcoPorcellato/commit-ci-preflight"}]},"pack_digest":"sha256:e544ed348f1b90ea6e5f07419a664640f14db07805ac3518b7be644b879ef8ff"} \ No newline at end of file diff --git a/tests/fixtures/capability-pack-v1/valid-minimal.toml b/tests/fixtures/capability-pack-v1/valid-minimal.toml index 520d1dd..56b56db 100644 --- a/tests/fixtures/capability-pack-v1/valid-minimal.toml +++ b/tests/fixtures/capability-pack-v1/valid-minimal.toml @@ -17,7 +17,7 @@ pass_semantics = "The exact pinned command exits zero." known_blind_spots = ["Does not prove dynamic behavior."] supported_hosts = ["macos-aarch64"] target_platforms = ["linux-arm64"] -required_runtime_features = ["linux-userland", "no-network", "read-only-source"] +required_runtime_features = ["linux-userland", "no-network", "read-only-source", "writable-caches"] offline_preparation = "none" [[profiles.tools]] From b5cc2ba272adbda2f148e8b05d61064a11361377 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:08:51 +0200 Subject: [PATCH 04/13] fix: reject capability pack shell bypasses --- src/capability_pack.rs | 38 ++++++++++++++++++------------- tests/capability_pack_contract.rs | 30 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/capability_pack.rs b/src/capability_pack.rs index 2d43c92..6fe0e07 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -643,28 +643,34 @@ fn shell_entrypoint(argv: &[String]) -> bool { let Some(entrypoint) = argv.first() else { return false; }; - let basename = entrypoint - .rsplit('/') - .next() - .unwrap_or(entrypoint) - .to_ascii_lowercase(); + let basename = executable_basename(entrypoint); if SHELLS.contains(&basename.as_str()) { return true; } if !matches!(basename.as_str(), "env") { return false; } - argv.iter() - .skip(1) - .find(|argument| !argument.starts_with('-')) - .is_some_and(|argument| { - let name = argument - .rsplit('/') - .next() - .unwrap_or(argument) - .to_ascii_lowercase(); - SHELLS.contains(&name.as_str()) - }) + for argument in argv.iter().skip(1) { + if matches!(argument.as_str(), "-S" | "--split-string") + || argument.starts_with("--split-string=") + || argument.starts_with("-S") + { + return true; + } + if argument.starts_with('-') || argument.contains('=') { + continue; + } + return SHELLS.contains(&executable_basename(argument).as_str()); + } + false +} + +fn executable_basename(argument: &str) -> String { + argument + .rsplit(['/', '\\']) + .next() + .unwrap_or(argument) + .to_ascii_lowercase() } fn validate_profile( diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index d6ef26b..ff00504 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -143,6 +143,36 @@ fn validator_rejects_shells_invoked_through_env() { )); } +#[test] +fn validator_rejects_shell_entrypoint_bypasses() { + for argv in [ + vec![ + "env".to_owned(), + "X=1".to_owned(), + "bash".to_owned(), + "-c".to_owned(), + "cargo clippy".to_owned(), + ], + vec![ + "env".to_owned(), + "-S".to_owned(), + "bash -c cargo clippy".to_owned(), + ], + vec![ + "C:\\Windows\\System32\\cmd.exe".to_owned(), + "/c".to_owned(), + "cargo clippy".to_owned(), + ], + ] { + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].argv = argv; + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::ShellEntrypoint(id)) if id == "clippy" + )); + } +} + #[test] fn normalized_pack_is_order_independent_and_matches_pinned_canonical_bytes() { let first = validate_fixture(VALID).expect("first pack"); From 7962c2ab2139444bb3dfffc9848582478078b22a Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:11:46 +0200 Subject: [PATCH 05/13] fix: parse capability pack env options --- src/capability_pack.rs | 25 +++++++++++++++++++++++-- tests/capability_pack_contract.rs | 17 +++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/capability_pack.rs b/src/capability_pack.rs index 6fe0e07..662d0c9 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -650,14 +650,35 @@ fn shell_entrypoint(argv: &[String]) -> bool { if !matches!(basename.as_str(), "env") { return false; } - for argument in argv.iter().skip(1) { + let mut arguments = argv.iter().skip(1); + while let Some(argument) = arguments.next() { if matches!(argument.as_str(), "-S" | "--split-string") || argument.starts_with("--split-string=") || argument.starts_with("-S") { return true; } - if argument.starts_with('-') || argument.contains('=') { + if matches!(argument.as_str(), "-u" | "-C" | "--unset" | "--chdir") { + if arguments.next().is_none() { + return true; + } + continue; + } + if argument.starts_with("-u") + || argument.starts_with("-C") + || argument.starts_with("--unset=") + || argument.starts_with("--chdir=") + || matches!( + argument.as_str(), + "-i" | "--ignore-environment" | "-0" | "--null" | "--" + ) + { + continue; + } + if argument.starts_with('-') { + return true; + } + if argument.contains('=') { continue; } return SHELLS.contains(&executable_basename(argument).as_str()); diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index ff00504..6a41097 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -173,6 +173,23 @@ fn validator_rejects_shell_entrypoint_bypasses() { } } +#[test] +fn validator_rejects_shells_after_env_option_operands() { + for argv in [ + vec!["env", "-u", "FOO", "bash", "-c", "cargo clippy"], + vec!["env", "-C", "/tmp", "bash", "-c", "cargo clippy"], + vec!["env", "--unset", "FOO", "bash", "-c", "cargo clippy"], + vec!["env", "--chdir", "/tmp", "bash", "-c", "cargo clippy"], + ] { + let mut manifest = valid_manifest(); + manifest.profiles[0].checks[0].argv = argv.into_iter().map(str::to_owned).collect(); + assert!(matches!( + manifest.validate(), + Err(CapabilityPackError::ShellEntrypoint(id)) if id == "clippy" + )); + } +} + #[test] fn normalized_pack_is_order_independent_and_matches_pinned_canonical_bytes() { let first = validate_fixture(VALID).expect("first pack"); From 58e8820ed38b08b9583dd9ca3d411e1f8d582219 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:15:45 +0200 Subject: [PATCH 06/13] feat: expand inert capability pack profiles --- src/capability_pack.rs | 70 +++++++++++++- tests/capability_pack_contract.rs | 96 ++++++++++++++++++- ...valid-minimal.strict-clippy.expansion.json | 1 + 3 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json diff --git a/src/capability_pack.rs b/src/capability_pack.rs index 662d0c9..f67e6a6 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -21,9 +21,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::config::{ - CacheConfig, CheckConfig, ConfigError, ConfigV1, EnvironmentConfig, NormalizedCache, - NormalizedCheck, NormalizedEnvironment, NormalizedRuntime, NormalizedStorage, ReceiptConfig, - RuntimeConfig, RuntimeKind, StorageConfig, + CacheConfig, CheckConfig, ConfigError, ConfigV1, EnvironmentConfig, ExecutionPlanEnvelopeV1, + NormalizedCache, NormalizedCheck, NormalizedEnvironment, NormalizedRuntime, NormalizedStorage, + ReceiptConfig, RuntimeConfig, RuntimeKind, StorageConfig, }; use crate::receipt::{ReceiptError, canonical_digest, canonical_json}; @@ -144,6 +144,24 @@ pub struct CapabilityPackEnvelopeV1 { profile_configs: BTreeMap, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityPackBindingV1 { + pub project: String, + pub profile_id: String, + pub receipt: ReceiptConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityPackExpansionV1 { + pub schema_version: String, + pub pack_id: String, + pub pack_version: String, + pub pack_digest: String, + pub profile_id: String, + pub evidence_class: CapabilityEvidenceClassV1, + pub execution_plan: ExecutionPlanEnvelopeV1, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct NormalizedCapabilityPackV1 { @@ -188,6 +206,42 @@ struct ValidatedProfileConfigV1 { } impl CapabilityPackEnvelopeV1 { + pub fn expand( + &self, + binding: CapabilityPackBindingV1, + ) -> Result { + let raw = self + .profile_configs + .get(&binding.profile_id) + .ok_or_else(|| CapabilityPackError::UnknownProfile(binding.profile_id.clone()))?; + let plan = ConfigV1 { + schema_version: "1.3".to_owned(), + project: binding.project, + runtime: raw.runtime.clone(), + receipt: binding.receipt, + environment: raw.environment.clone(), + caches: raw.caches.clone(), + storage: Some(raw.storage.clone()), + checks: raw.checks.clone(), + } + .into_plan()?; + let profile = self + .pack + .profiles + .iter() + .find(|profile| profile.id == binding.profile_id) + .ok_or_else(|| CapabilityPackError::UnknownProfile(binding.profile_id.clone()))?; + Ok(CapabilityPackExpansionV1 { + schema_version: self.pack.schema_version.clone(), + pack_id: self.pack.pack_id.clone(), + pack_version: self.pack.pack_version.clone(), + pack_digest: self.pack_digest.clone(), + profile_id: binding.profile_id, + evidence_class: profile.evidence_class, + execution_plan: plan, + }) + } + pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { if canonical_digest(&self.pack)? != self.pack_digest { return Err(CapabilityPackError::PackDigestMismatch); @@ -199,6 +253,16 @@ impl CapabilityPackEnvelopeV1 { } } +impl CapabilityPackExpansionV1 { + pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { + if !self.pack_digest.starts_with("sha256:") { + return Err(CapabilityPackError::PackDigestMismatch); + } + self.execution_plan.canonical_bytes()?; + Ok(canonical_json(self)?) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CapabilitySourceV1 { diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index 6a41097..7b800fc 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -13,11 +13,13 @@ // limitations under the License. use commit_ci_preflight::capability_pack::{ - CAPABILITY_PACK_SCHEMA_VERSION, CapabilityInputKindV1, CapabilityPackEnvelopeV1, - CapabilityPackError, CapabilityPackManifestV1, CapabilityRuntimeFeatureV1, - MAX_CAPABILITY_PACK_BYTES, + CAPABILITY_PACK_SCHEMA_VERSION, CapabilityInputKindV1, CapabilityPackBindingV1, + CapabilityPackEnvelopeV1, CapabilityPackError, CapabilityPackManifestV1, + CapabilityRuntimeFeatureV1, MAX_CAPABILITY_PACK_BYTES, }; use commit_ci_preflight::config::{ConfigError, RuntimeKind}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; const VALID: &str = include_str!("fixtures/capability-pack-v1/valid-minimal.toml"); const UNKNOWN_FIELD: &str = include_str!("fixtures/capability-pack-v1/unknown-field.toml"); @@ -33,6 +35,48 @@ const REORDERED_VALID: &str = include_str!("fixtures/capability-pack-v1/valid-minimal-reordered.toml"); const PINNED_CANONICAL: &[u8] = include_bytes!("fixtures/capability-pack-v1/valid-minimal.canonical.json"); +const PINNED_EXPANSION: &[u8] = + include_bytes!("fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json"); +static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +fn valid_binding() -> CapabilityPackBindingV1 { + CapabilityPackBindingV1 { + project: "example/project".to_owned(), + profile_id: "strict-clippy".to_owned(), + receipt: commit_ci_preflight::config::ReceiptConfig { + output: ".ccp/receipt.json".to_owned(), + freshness_seconds: 300, + }, + } +} +fn binding_for(profile_id: &str) -> CapabilityPackBindingV1 { + CapabilityPackBindingV1 { + profile_id: profile_id.to_owned(), + ..valid_binding() + } +} +fn binding_for_project(project: &str) -> CapabilityPackBindingV1 { + CapabilityPackBindingV1 { + project: project.to_owned(), + ..valid_binding() + } +} +fn unique_test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "ccp-{label}-{}-{}", + std::process::id(), + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) +} +fn valid_manifest_with_argv(argv: Vec) -> String { + let original = "argv = [\"cargo\", \"clippy\", \"--locked\", \"--offline\", \"--all-targets\", \"--all-features\", \"--\", \"-D\", \"warnings\"]"; + let replacement = format!( + "argv = {}", + serde_json::to_string(&argv).expect("argv JSON") + ); + assert!(VALID.contains(original)); + VALID.replacen(original, &replacement, 1) +} fn validate_fixture(source: &str) -> Result { CapabilityPackManifestV1::parse(source)?.validate() @@ -673,3 +717,49 @@ fn canonical_bytes_rejects_tampered_digest() { Err(CapabilityPackError::PackDigestMismatch) )); } + +#[test] +fn one_explicit_profile_expands_to_one_existing_plan_envelope() { + let pack = validate_fixture(VALID).expect("pack"); + let expansion = pack.expand(valid_binding()).expect("expansion"); + assert_eq!(expansion.pack_digest, pack.pack_digest); + assert_eq!(expansion.profile_id, "strict-clippy"); + assert_eq!(expansion.execution_plan.plan.project, "example/project"); + assert_eq!(expansion.execution_plan.plan.schema_version, "1.3"); + assert_eq!( + expansion.canonical_bytes().expect("canonical expansion"), + PINNED_EXPANSION + ); +} + +#[test] +fn inspection_and_expansion_never_execute_declared_argv() { + let root = unique_test_root("pack-inertness"); + std::fs::create_dir_all(&root).expect("create owned test root"); + let marker = root.join("must-not-exist"); + let source = valid_manifest_with_argv(vec![ + "/usr/bin/touch".to_owned(), + marker.display().to_string(), + ]); + let pack = CapabilityPackManifestV1::parse(&source) + .and_then(CapabilityPackManifestV1::validate) + .expect("inert pack"); + let _inspection = pack.inspection(); + let _expansion = pack.expand(valid_binding()).expect("inert expansion"); + assert!(!marker.exists()); + std::fs::remove_dir(&root).expect("remove empty owned test root"); +} + +#[test] +fn expansion_rejects_unknown_profile_and_invalid_repository_binding() { + let pack = validate_fixture(VALID).expect("pack"); + assert!( + matches!(pack.expand(binding_for("missing")), Err(CapabilityPackError::UnknownProfile(id)) if id == "missing") + ); + assert!(matches!( + pack.expand(binding_for_project("not-a-repository")), + Err(CapabilityPackError::Config(ConfigError::InvalidField( + "project" + ))) + )); +} diff --git a/tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json b/tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json new file mode 100644 index 0000000..a039053 --- /dev/null +++ b/tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json @@ -0,0 +1 @@ +{"evidence_class":"deterministic","execution_plan":{"plan":{"caches":[{"id":"cargo","mount_path":".cache/cargo"}],"checks":[{"argv":["cargo","clippy","--locked","--offline","--all-targets","--all-features","--","-D","warnings"],"artifacts":[],"depends_on":[],"id":"clippy","required":true,"timeout_seconds":1800,"working_directory":"."}],"environment":{"fixed":[{"name":"CARGO_NET_OFFLINE","value_digest":"sha256:18d10c7d2b4b04aaf04254d1ae5d655a5dc0407cbcdd5a8c3986e985370f36ee"}],"inherit":[],"remote_secret_only":[],"runtime_internal":[]},"project":"example/project","receipt":{"freshness_seconds":300,"output":".ccp/receipt.json"},"runtime":{"cpu_count":2,"image":"ghcr.io/example/rust@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","kind":"docker_compatible","memory_mib":2048,"network":false,"pids_limit":256,"pull_policy":"never","swap_mode":"disabled"},"schema_version":"1.3","storage":{"max_artifact_bytes":0,"max_cache_growth_bytes":1048576,"min_free_bytes":1048576,"receipt_journal_reserve_bytes":4096}},"plan_digest":"sha256:a9b539df46b4f319006a1c5aac94691783adbfff46d893351f0f60575dbb755f"},"pack_digest":"sha256:e544ed348f1b90ea6e5f07419a664640f14db07805ac3518b7be644b879ef8ff","pack_id":"ccp.rust-minimal","pack_version":"1.0.0","profile_id":"strict-clippy","schema_version":"1.0"} \ No newline at end of file From b0cddc02cd534f295cc45c6c5060aeea13d0f171 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:18:21 +0200 Subject: [PATCH 07/13] fix: validate capability expansion digest format --- src/capability_pack.rs | 4 +--- tests/capability_pack_contract.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/capability_pack.rs b/src/capability_pack.rs index f67e6a6..da0df7b 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -255,9 +255,7 @@ impl CapabilityPackEnvelopeV1 { impl CapabilityPackExpansionV1 { pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { - if !self.pack_digest.starts_with("sha256:") { - return Err(CapabilityPackError::PackDigestMismatch); - } + validate_digest("pack_digest", &self.pack_digest)?; self.execution_plan.canonical_bytes()?; Ok(canonical_json(self)?) } diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index 7b800fc..f0d4170 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -763,3 +763,20 @@ fn expansion_rejects_unknown_profile_and_invalid_repository_binding() { ))) )); } + +#[test] +fn expansion_canonical_bytes_rejects_malformed_pack_digest() { + let pack = validate_fixture(VALID).expect("pack"); + let mut expansion = pack.expand(valid_binding()).expect("expansion"); + for digest in [ + "sha256:".to_owned(), + "sha256:ABC".to_owned(), + "sha256:".to_owned() + &("0".repeat(63) + "g"), + ] { + expansion.pack_digest = digest; + assert!(matches!( + expansion.canonical_bytes(), + Err(CapabilityPackError::InvalidField("pack_digest")) + )); + } +} From da3849b0c6b06d7992ee4c68cf5d9d6e2781425f Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:21:11 +0200 Subject: [PATCH 08/13] docs: define capability pack contract --- CHANGELOG.md | 4 + docs/CAPABILITY_PACKS.md | 74 ++++ schema/capability-pack-v1.schema.json | 538 ++++++++++++++++++++++++++ src/capability_pack.rs | 7 +- tests/capability_pack_contract.rs | 10 + 5 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 docs/CAPABILITY_PACKS.md create mode 100644 schema/capability-pack-v1.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1c886..dccb4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Semantic Versioning after its first public release. ### Added +- Added the pre-1.0 additive Rust capability-pack contract and pinned schema; + this does not change existing CLI or receipt schemas and does not enable + official pack execution. + - Added auditable economic qualification guidance with machine-readable August 2026 case-study inputs for Matryca-Knowledge and the private case published as Matryca-Brain. The documentation separates quota preservation and GitHub diff --git a/docs/CAPABILITY_PACKS.md b/docs/CAPABILITY_PACKS.md new file mode 100644 index 0000000..369b9a8 --- /dev/null +++ b/docs/CAPABILITY_PACKS.md @@ -0,0 +1,74 @@ +# Capability packs + +Status: schema and inert library inspection/expansion only; no official pack execution in M2. + +## TOML schema 1.0 + +Manifests use `schema_version = "1.0"` and strict TOML fields. A manifest is at +most 1 MiB; it has one identity (`pack_id`, `pack_version`, `license`, and +`description`), upstream sources, and 1–32 profiles. Each profile has a unique +identifier, bounded metadata, tools (at most 32), inputs (at most 64), hosts, +targets, runtime, environment, caches, storage, and checks. Lists are bounded +to the limits enforced by the library; paths and argv are validated as safe, +shell-free values. + +## Identity and versions + +The pack identity tuple is `(pack_id, pack_version, license, pack_digest)`. +Versions are immutable: publishing a changed manifest requires a new version +and therefore a new digest. + +## Images and provenance + +Runtime images must be digest-pinned. Tools and upstream inputs carry explicit +SHA-256-style provenance digests and URLs; an unpinned or missing provenance +value is rejected. + +## Licensing + +License values use SPDX-style identifier syntax. Syntax validation is not legal +review, and operators remain responsible for licensing and attribution review. + +## Integrity and freshness + +Integrity checks prove that declared bytes match their digest. Database and +rules freshness is separate: inputs may declare a creation timestamp and a +maximum age, but a valid digest does not make stale data fresh. + +## Profile binding and expansion + +Consumers bind a project, profile identifier, and receipt configuration +explicitly. Expansion resolves exactly one profile into exactly one normalized +execution plan, preserving the pack digest and evidence class. + +## Expansion is not execution + +Expansion only constructs an inspectable plan. It does not acquire resources, +start a runtime, run checks, create receipts, or qualify a result. No CLI +command exists for official pack execution yet. + +## Preparation and I/O boundaries + +Network access is disabled. Any required preparation is external to this +library. Sources are read-only; outputs must be explicit paths and bounded by +the existing plan contracts. + +## Evidence classes + +`deterministic` evidence should repeat exactly. `schedule-sensitive` evidence +can vary with scheduling or resource timing. `bounded-nondeterministic` +evidence may vary within documented bounds. The class describes evidence; it +does not qualify an execution. + +## Non-goals + +M2 adds no workflow DSL, package manager, tool installer, report interpreter, +receipt extension, publication mechanism, or policy override. + +## M3 entry criteria + +M3 may propose the `rust-deep` reference pack only after a reviewed manifest, +digest-pinned image and provenance, explicit offline preparation, bounded +inputs/outputs, profile-to-plan tests, freshness and integrity evidence, and a +separate execution/qualification decision. No such official pack is executable +from this M2 contract. diff --git a/schema/capability-pack-v1.schema.json b/schema/capability-pack-v1.schema.json new file mode 100644 index 0000000..4716c6e --- /dev/null +++ b/schema/capability-pack-v1.schema.json @@ -0,0 +1,538 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CapabilityPackManifestV1", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "license": { + "type": "string" + }, + "pack_id": { + "type": "string" + }, + "pack_version": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilityProfileConfigV1" + } + }, + "schema_version": { + "type": "string" + }, + "upstream_sources": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilitySourceV1" + } + } + }, + "additionalProperties": false, + "required": [ + "schema_version", + "pack_id", + "pack_version", + "license", + "description", + "upstream_sources", + "profiles" + ], + "$defs": { + "ArtifactContractConfig": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/ArtifactKind" + }, + "max_bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "max_entries": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "path", + "kind", + "max_bytes", + "max_entries" + ] + }, + "ArtifactKind": { + "type": "string", + "enum": [ + "regular-file", + "directory" + ] + }, + "CacheConfig": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "mount_path": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "mount_path" + ] + }, + "CapabilityEvidenceClassV1": { + "type": "string", + "enum": [ + "deterministic", + "schedule-sensitive", + "bounded-nondeterministic" + ] + }, + "CapabilityHostPlatformV1": { + "type": "string", + "enum": [ + "macos-aarch64", + "linux-arm64", + "linux-amd64", + "windows-amd64" + ] + }, + "CapabilityInputKindV1": { + "type": "string", + "enum": [ + "rules", + "type-stubs", + "corpus", + "advisory-database", + "vulnerability-database" + ] + }, + "CapabilityInputProvenanceV1": { + "type": "object", + "properties": { + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/CapabilityInputKindV1" + }, + "max_age_seconds": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "default": null, + "minimum": 0 + }, + "snapshot_created_at_utc": { + "type": [ + "string", + "null" + ], + "default": null + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "kind", + "url", + "digest" + ] + }, + "CapabilityProfileConfigV1": { + "type": "object", + "properties": { + "caches": { + "type": "array", + "items": { + "$ref": "#/$defs/CacheConfig" + } + }, + "checks": { + "type": "array", + "items": { + "$ref": "#/$defs/CheckConfig" + } + }, + "description": { + "type": "string" + }, + "environment": { + "$ref": "#/$defs/EnvironmentConfig" + }, + "evidence_class": { + "$ref": "#/$defs/CapabilityEvidenceClassV1" + }, + "id": { + "type": "string" + }, + "inputs": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/CapabilityInputProvenanceV1" + } + }, + "known_blind_spots": { + "type": "array", + "items": { + "type": "string" + } + }, + "offline_preparation": { + "$ref": "#/$defs/OfflinePreparationV1" + }, + "pass_semantics": { + "type": "string" + }, + "required_runtime_features": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilityRuntimeFeatureV1" + } + }, + "runtime": { + "$ref": "#/$defs/RuntimeConfig" + }, + "storage": { + "$ref": "#/$defs/StorageConfig" + }, + "supported_hosts": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilityHostPlatformV1" + } + }, + "target_platforms": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilityTargetPlatformV1" + } + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/CapabilityToolV1" + } + } + }, + "additionalProperties": false, + "required": [ + "id", + "description", + "evidence_class", + "pass_semantics", + "known_blind_spots", + "supported_hosts", + "target_platforms", + "required_runtime_features", + "offline_preparation", + "tools", + "runtime", + "storage", + "checks" + ] + }, + "CapabilityRuntimeFeatureV1": { + "type": "string", + "enum": [ + "linux-userland", + "no-network", + "read-only-source", + "writable-caches", + "bounded-artifacts" + ] + }, + "CapabilitySourceV1": { + "type": "object", + "properties": { + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "url", + "digest" + ] + }, + "CapabilityTargetPlatformV1": { + "type": "string", + "enum": [ + "linux-arm64", + "linux-amd64" + ] + }, + "CapabilityToolV1": { + "type": "object", + "properties": { + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "license": { + "type": "string" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "version", + "license", + "url", + "digest" + ] + }, + "CheckConfig": { + "type": "object", + "properties": { + "argv": { + "type": "array", + "items": { + "type": "string" + } + }, + "artifact_contracts": { + "type": "array", + "items": { + "$ref": "#/$defs/ArtifactContractConfig" + } + }, + "artifacts": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "depends_on": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "id": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "timeout_seconds": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "working_directory": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "required", + "argv", + "working_directory", + "timeout_seconds" + ] + }, + "EnvironmentConfig": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "fixed": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "remote_secret_only": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "runtime_internal": { + "type": "array", + "items": { + "$ref": "#/$defs/RuntimeInternalEnvironmentConfig" + } + } + }, + "additionalProperties": false + }, + "OfflinePreparationV1": { + "type": "string", + "enum": [ + "none", + "required-external" + ] + }, + "RuntimeConfig": { + "type": "object", + "properties": { + "cpu_count": { + "type": "integer", + "format": "uint16", + "maximum": 65535, + "minimum": 0 + }, + "image": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/RuntimeKind" + }, + "memory_mib": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "network": { + "type": "boolean", + "default": false + }, + "pids_limit": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "pull_policy": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimePullPolicy" + }, + { + "type": "null" + } + ], + "default": null + }, + "swap_mode": { + "anyOf": [ + { + "$ref": "#/$defs/RuntimeSwapMode" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "additionalProperties": false, + "required": [ + "kind", + "image", + "cpu_count", + "memory_mib", + "pids_limit" + ] + }, + "RuntimeInternalEnvironmentConfig": { + "type": "object", + "properties": { + "cache_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "cache_id" + ] + }, + "RuntimeKind": { + "type": "string", + "enum": [ + "docker_compatible", + "host" + ] + }, + "RuntimePullPolicy": { + "type": "string", + "enum": [ + "never" + ] + }, + "RuntimeSwapMode": { + "type": "string", + "enum": [ + "disabled" + ] + }, + "StorageConfig": { + "type": "object", + "properties": { + "max_cache_growth_bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "min_free_bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "receipt_journal_reserve_bytes": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "min_free_bytes", + "receipt_journal_reserve_bytes", + "max_cache_growth_bytes" + ] + } + } +} \ No newline at end of file diff --git a/src/capability_pack.rs b/src/capability_pack.rs index da0df7b..5f7b4c0 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -17,7 +17,7 @@ use std::fmt; use std::fs; use std::path::{Path, PathBuf}; -use schemars::JsonSchema; +use schemars::{JsonSchema, schema_for}; use serde::{Deserialize, Serialize}; use crate::config::{ @@ -29,6 +29,11 @@ use crate::receipt::{ReceiptError, canonical_digest, canonical_json}; pub const CAPABILITY_PACK_SCHEMA_VERSION: &str = "1.0"; pub const MAX_CAPABILITY_PACK_BYTES: usize = 1_048_576; + +pub fn capability_pack_schema_json() -> Result { + let schema = schema_for!(CapabilityPackManifestV1); + serde_json::to_string_pretty(&schema).map_err(CapabilityPackError::Json) +} const MAX_PACK_PROFILES: usize = 32; const MAX_PACK_SOURCES: usize = 16; const MAX_PROFILE_TOOLS: usize = 32; diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index f0d4170..4064089 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -17,6 +17,7 @@ use commit_ci_preflight::capability_pack::{ CapabilityPackEnvelopeV1, CapabilityPackError, CapabilityPackManifestV1, CapabilityRuntimeFeatureV1, MAX_CAPABILITY_PACK_BYTES, }; +const PINNED_SCHEMA: &str = include_str!("../schema/capability-pack-v1.schema.json"); use commit_ci_preflight::config::{ConfigError, RuntimeKind}; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -39,6 +40,15 @@ const PINNED_EXPANSION: &[u8] = include_bytes!("fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json"); static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); +#[test] +fn generated_capability_pack_schema_matches_pinned_bytes() { + assert_eq!( + commit_ci_preflight::capability_pack::capability_pack_schema_json() + .expect("capability pack schema"), + PINNED_SCHEMA + ); +} + fn valid_binding() -> CapabilityPackBindingV1 { CapabilityPackBindingV1 { project: "example/project".to_owned(), From 3f77c426681d3118e5e00cfeee5bb7c9c8c2b663 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:31:34 +0200 Subject: [PATCH 09/13] docs: close capability pack contract milestone --- .../m2-manifest.json | 24 ++++++ .../progress.md | 11 ++- tests/capability_pack_contract.rs | 82 ++++++++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json new file mode 100644 index 0000000..dcf496f --- /dev/null +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0", + "base_commit": "da3849b0c6b06d7992ee4c68cf5d9d6e2781425f", + "files": [ + {"path":"CHANGELOG.md","bytes":20296,"sha256":"sha256:bf3cd42627ff40e23490d0cf4a36d640e5c08feaef2af4d1779172464722b54c"}, + {"path":"docs/CAPABILITY_PACKS.md","bytes":2939,"sha256":"sha256:7b535ca9fd4ade867ff836e6fc3bce6cb08ab0ca4bec3eb022e73c10eef564aa"}, + {"path":"schema/capability-pack-v1.schema.json","bytes":11325,"sha256":"sha256:2c35b17807ef85a4ba79343c3d1948fd185ca7d5f1dc9a6b1dbf15b9635b8f09"}, + {"path":"src/capability_pack.rs","bytes":31120,"sha256":"sha256:1956ef98ef0d5464c55df7668dcb053d393037aa3a4310aca2686eef1a7c3566"}, + {"path":"src/lib.rs","bytes":1036,"sha256":"sha256:55f5edc35ac49ff0dbcb10723986c3da239b23788a757c232d14071e8c20644b"}, + {"path":"tests/capability_pack_contract.rs","bytes":30547,"sha256":"sha256:84dfe862253c166a6856bcaa12f367f1dcfbb5e39a073f7e0f8a16b3580ef9a0"}, + {"path":"tests/fixtures/capability-pack-v1/dependency-cycle.toml","bytes":2067,"sha256":"sha256:aa88ff2c9931d23ddba372f045bc402bad514085fa13a3f159dbf78172030e3d"}, + {"path":"tests/fixtures/capability-pack-v1/invalid-image.toml","bytes":1910,"sha256":"sha256:64cbf330a32076072587b0498e8248284e8c67ba0370b35b0a278e980360f7ab"}, + {"path":"tests/fixtures/capability-pack-v1/invalid-license.toml","bytes":1989,"sha256":"sha256:1d42945238ec458f03cfee56225894febf6d35b7398e26c5698c2746f614df29"}, + {"path":"tests/fixtures/capability-pack-v1/invalid-path.toml","bytes":1978,"sha256":"sha256:55978cd2012ba27ca09184fe3384fd6970c58dc415548672eb723b43bd6a1f03"}, + {"path":"tests/fixtures/capability-pack-v1/invalid-provenance.toml","bytes":1982,"sha256":"sha256:1d02a3904553e47cb6db807197ef92670634689382cb89429efbc0d3942aa5b5"}, + {"path":"tests/fixtures/capability-pack-v1/shell-entrypoint.toml","bytes":1912,"sha256":"sha256:8dcafc09f7e3d700ed2b71ebe6022701a4d69a71183b392901d25e349afda220"}, + {"path":"tests/fixtures/capability-pack-v1/unknown-field.toml","bytes":1981,"sha256":"sha256:5b9231953f27054741d8e9f6fe407002e1aa52d6c5bec10f25f4d88d74c4fb40"}, + {"path":"tests/fixtures/capability-pack-v1/unknown-version.toml","bytes":1963,"sha256":"sha256:fb264d537d8d5a350a954bc031cc61ca0c9deb4340501922d311c2728960e988"}, + {"path":"tests/fixtures/capability-pack-v1/valid-minimal.canonical.json","bytes":2217,"sha256":"sha256:ae9ccb5ea2e26759b8f8364fc19aebf112b8dcd70075c33a4c952e489559b8ce"}, + {"path":"tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml","bytes":1982,"sha256":"sha256:1b790425e89bc32eebd1f5b96e5cbaa7d85a5c2d5559831bcb62abd687f7f7e3"}, + {"path":"tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json","bytes":1321,"sha256":"sha256:20859fc142a923b9f3b46bbb5d11e685b39128eda1d9c59269bcdad56decc602"}, + {"path":"tests/fixtures/capability-pack-v1/valid-minimal.toml","bytes":1982,"sha256":"sha256:3e90fb7b3c6294de423651d22a2f23b3b4ab20bd7e66f48f996535ad46f6b2eb"} + ] +} diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md index 8a6c882..7c23ef3 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md @@ -4,7 +4,7 @@ - Predecessor: `17e069a7eb3bcc6596c93bb6432984eba8472208` - Branch: `codex/capability-packs-clean-architecture-delivery-v1` - Specification commit: `5ef9707930f7095a2f57bc3e38e53bfeac06aaf2` -- Current milestone: M2 Capability Pack contract plan (ready; implementation gated by hosted CI) +- Current milestone: M2 Capability Pack contract closure candidate; required local commit and controller-owned dual review pending - Completed evidence: design review READY; `git diff --check` PASS; M0 compatibility manifest and downstream facade checks independently approved at `9e689c7f04e3d8c0479c74d91669c170a4c66e52` - Terminal M0 verification: `rtk cargo fmt --check` PASS; focused manifest test PASS; full compatibility baseline PASS (9 passed, 1 ignored); `rtk git diff --check` PASS; bounded privacy scan PASS (zero matches) - M0 status: terminally closed; Spec PASS and Task quality Approved @@ -17,7 +17,14 @@ - M2 plan commit: `a1c922d4f47fd06e74a336b89a19aecc703908bd` - M2 plan decision: TOML-only schema `1.0`; library-first inert validation/inspection; explicit one-profile binding into the existing schema-`1.3` plan; separate pack and plan digests; no CLI, receipt, policy, execution, or publication change in M2 - M2 plan review: initial NOT_READY findings for implicit types/helpers and missing error mappings were corrected; final Luna re-review READY with no Critical or Important findings -- Unproven: hosted CI for the exact M0-M1-plus-plan head, push, draft PR, publication, and release gates; all M2 implementation; reference packs +- M2 accepted implementation commits: Task 1 `1cb0f03ea83b6b3bfd53319345d16d909ec0e3fe`, review fix `324476e6d53c4b727ccbc41cff7884ec0c6d37f6`; Task 2 `d4d6f5fb4f9612c1f4a0f1ada4235731e3a55380`, review fixes `b5cc2ba272adbda2f148e8b05d61064a11361377` and `7962c2ab2139444bb3dfffc9848582478078b22a`; Task 3 `58e8820ed38b08b9583dd9ca3d411e1f8d582219`, review fix `b0cddc02cd534f295cc45c6c506aeea13d0f171`; Task 4 `da3849b0c6b06d7992ee4c68cf5d9d6e2781425f`. +- M2 accepted reviews: Task 1 scoped re-review PASS after one Important fix; Task 2 scoped re-reviews PASS after two shell-parser fixes, with one parked Minor duplicate-ID assertion concern; Task 3 scoped re-review PASS after its exact-digest validation fix; Task 4 independent review PASS with no Critical, Important, or Minor findings. Task 5 dual review remains pending and is not claimed. +- M2 pre-closure anchor/current HEAD: `da3849b0c6b06d7992ee4c68cf5d9d6e2781425f` (`docs: define capability pack contract`); before the required Task 5 commit, the candidate worktree is dirty only in Task 5-owned closure files. +- M2 local qualification: rustfmt PASS; strict workspace Clippy PASS; capability-pack contract 20 PASS before Task 5 test; compatibility baseline 9 PASS, 1 ignored; plan CLI 11 PASS; matrix contract 16 PASS; receipt contract 10 PASS; verification contract 20 PASS, 1 ignored. The original sandbox broad run stopped at 279 PASS, 3 `Operation not permitted` denials, 1 ignored. Controller repeated the same exact broad command under narrow host permission: 487 PASS, 5 ignored, 28 suites, 7.65s. The host result is qualification evidence; sandbox denials are neither product failures nor PASS. +- M2 compatibility evidence: scoped diff against `5fed7c443504969e62980141048f9279f9fa1dfe` over CLI/config/matrix/receipt/verify and v1/v2 schema surfaces was empty. The available exact compatibility manifest test `manifest_paths_and_hashes_match` passed 1/1. The brief's named `manifest_matches_the_exact_compatibility_corpus` filter selected no test (0 PASS, 10 filtered) and is not treated as evidence. +- M2 closure evidence: `m2-manifest.json` schema `1.0` anchors the pre-closure HEAD and lists 18 sorted path/byte/SHA-256 entries. Its contract test observed RED because the manifest was absent, then GREEN 1/1 after creation. +- M2 residual boundary: inert library validation and expansion only. No official pack, CLI entry point, tool/image qualification, Docker execution, hosted exact-head result, push, PR update, merge, stable installation, tag, or release is implied. M3 must first review the `rust-deep` tool/image/license matrix and design the smallest user entry point without weakening M0 compatibility guarantees. +- Unproven: hosted CI for exact Task 5 candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. - Heavy processes: none - External mutations: none - Restart checkpoint: requested 2026-08-30; tracked handoff and resume prompt added under `restart/2026-08-30/`; the final exact branch and external archive hashes are recorded in the persistent `RECOVERY_MANIFEST.md` diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index 4064089..816ebe9 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -19,7 +19,8 @@ use commit_ci_preflight::capability_pack::{ }; const PINNED_SCHEMA: &str = include_str!("../schema/capability-pack-v1.schema.json"); use commit_ci_preflight::config::{ConfigError, RuntimeKind}; -use std::path::PathBuf; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; const VALID: &str = include_str!("fixtures/capability-pack-v1/valid-minimal.toml"); @@ -49,6 +50,85 @@ fn generated_capability_pack_schema_matches_pinned_bytes() { ); } +#[test] +fn m2_manifest_matches_exact_file_bytes() { + const EXPECTED_PATHS: [&str; 18] = [ + "CHANGELOG.md", + "docs/CAPABILITY_PACKS.md", + "schema/capability-pack-v1.schema.json", + "src/capability_pack.rs", + "src/lib.rs", + "tests/capability_pack_contract.rs", + "tests/fixtures/capability-pack-v1/dependency-cycle.toml", + "tests/fixtures/capability-pack-v1/invalid-image.toml", + "tests/fixtures/capability-pack-v1/invalid-license.toml", + "tests/fixtures/capability-pack-v1/invalid-path.toml", + "tests/fixtures/capability-pack-v1/invalid-provenance.toml", + "tests/fixtures/capability-pack-v1/shell-entrypoint.toml", + "tests/fixtures/capability-pack-v1/unknown-field.toml", + "tests/fixtures/capability-pack-v1/unknown-version.toml", + "tests/fixtures/capability-pack-v1/valid-minimal.canonical.json", + "tests/fixtures/capability-pack-v1/valid-minimal-reordered.toml", + "tests/fixtures/capability-pack-v1/valid-minimal.strict-clippy.expansion.json", + "tests/fixtures/capability-pack-v1/valid-minimal.toml", + ]; + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest_path = root.join( + "docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json", + ); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).expect("read M2 manifest")) + .expect("parse M2 manifest"); + let object = manifest.as_object().expect("M2 manifest object"); + assert_eq!( + object + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from(["base_commit", "files", "schema_version"]) + ); + assert_eq!(manifest["schema_version"], "1.0"); + assert_eq!( + manifest["base_commit"], + "da3849b0c6b06d7992ee4c68cf5d9d6e2781425f" + ); + let entries = manifest["files"].as_array().expect("M2 manifest files"); + let paths = entries + .iter() + .map(|entry| { + let entry = entry.as_object().expect("M2 manifest file entry"); + assert_eq!( + entry + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from(["bytes", "path", "sha256"]) + ); + entry["path"].as_str().expect("M2 manifest path") + }) + .collect::>(); + assert_eq!(paths, EXPECTED_PATHS); + + for entry in entries { + let relative = entry["path"].as_str().expect("M2 manifest path"); + let bytes = std::fs::read(root.join(relative)).expect("read manifested file"); + assert_eq!( + entry["bytes"].as_u64(), + Some(bytes.len() as u64), + "{relative}" + ); + let digest = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!( + entry["sha256"].as_str(), + Some(format!("sha256:{digest}").as_str()), + "{relative}" + ); + } +} + fn valid_binding() -> CapabilityPackBindingV1 { CapabilityPackBindingV1 { project: "example/project".to_owned(), From 2e6286cc23584d5e82842aacf106c3bb5e7462df Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:45:14 +0200 Subject: [PATCH 10/13] fix: harden capability pack boundaries --- docs/CAPABILITY_PACKS.md | 16 +- .../m2-manifest.json | 8 +- .../progress.md | 6 +- src/capability_pack.rs | 158 +++++++++++++++--- tests/capability_pack_contract.rs | 87 +++++++++- 5 files changed, 241 insertions(+), 34 deletions(-) diff --git a/docs/CAPABILITY_PACKS.md b/docs/CAPABILITY_PACKS.md index 369b9a8..85046c4 100644 --- a/docs/CAPABILITY_PACKS.md +++ b/docs/CAPABILITY_PACKS.md @@ -9,12 +9,13 @@ most 1 MiB; it has one identity (`pack_id`, `pack_version`, `license`, and `description`), upstream sources, and 1–32 profiles. Each profile has a unique identifier, bounded metadata, tools (at most 32), inputs (at most 64), hosts, targets, runtime, environment, caches, storage, and checks. Lists are bounded -to the limits enforced by the library; paths and argv are validated as safe, -shell-free values. +to the limits enforced by the library; relative paths are validated and a +finite known-shell denylist rejects declared shell entrypoints. It does not +establish that arbitrary argv is safe or shell-free. ## Identity and versions -The pack identity tuple is `(pack_id, pack_version, license, pack_digest)`. +The pack identity tuple is `(pack_id, pack_version, pack_digest)`. Versions are immutable: publishing a changed manifest requires a new version and therefore a new digest. @@ -31,9 +32,12 @@ review, and operators remain responsible for licensing and attribution review. ## Integrity and freshness -Integrity checks prove that declared bytes match their digest. Database and -rules freshness is separate: inputs may declare a creation timestamp and a -maximum age, but a valid digest does not make stale data fresh. +Pack canonical integrity verifies the normalized pack against its pack digest. +Declared provenance digests are syntax-checked declarations, not proof that +external bytes match. External preparation and qualification must obtain and +verify those bytes. Database and rules freshness is separate: inputs may +declare a creation timestamp and a maximum age, but a valid digest does not +make stale data fresh. ## Profile binding and expansion diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json index dcf496f..6730766 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json @@ -1,13 +1,13 @@ { "schema_version": "1.0", - "base_commit": "da3849b0c6b06d7992ee4c68cf5d9d6e2781425f", + "base_commit": "3f77c426681d3118e5e00cfeee5bb7c9c8c2b663", "files": [ {"path":"CHANGELOG.md","bytes":20296,"sha256":"sha256:bf3cd42627ff40e23490d0cf4a36d640e5c08feaef2af4d1779172464722b54c"}, - {"path":"docs/CAPABILITY_PACKS.md","bytes":2939,"sha256":"sha256:7b535ca9fd4ade867ff836e6fc3bce6cb08ab0ca4bec3eb022e73c10eef564aa"}, + {"path":"docs/CAPABILITY_PACKS.md","bytes":3226,"sha256":"sha256:cfb1dedb6b0793b7ffce2a8012469d673e2c823b0dbfd494d4d22495d81ae212"}, {"path":"schema/capability-pack-v1.schema.json","bytes":11325,"sha256":"sha256:2c35b17807ef85a4ba79343c3d1948fd185ca7d5f1dc9a6b1dbf15b9635b8f09"}, - {"path":"src/capability_pack.rs","bytes":31120,"sha256":"sha256:1956ef98ef0d5464c55df7668dcb053d393037aa3a4310aca2686eef1a7c3566"}, + {"path":"src/capability_pack.rs","bytes":35182,"sha256":"sha256:d6bcb6fe88fe68448f6be6425a55af2914da477f1bab209862a1193f3d03b59f"}, {"path":"src/lib.rs","bytes":1036,"sha256":"sha256:55f5edc35ac49ff0dbcb10723986c3da239b23788a757c232d14071e8c20644b"}, - {"path":"tests/capability_pack_contract.rs","bytes":30547,"sha256":"sha256:84dfe862253c166a6856bcaa12f367f1dcfbb5e39a073f7e0f8a16b3580ef9a0"}, + {"path":"tests/capability_pack_contract.rs","bytes":33594,"sha256":"sha256:a23ffd9bd5ae2caa51c97a6ad971cde09abb1ca7700fd9b23243f1019d698329"}, {"path":"tests/fixtures/capability-pack-v1/dependency-cycle.toml","bytes":2067,"sha256":"sha256:aa88ff2c9931d23ddba372f045bc402bad514085fa13a3f159dbf78172030e3d"}, {"path":"tests/fixtures/capability-pack-v1/invalid-image.toml","bytes":1910,"sha256":"sha256:64cbf330a32076072587b0498e8248284e8c67ba0370b35b0a278e980360f7ab"}, {"path":"tests/fixtures/capability-pack-v1/invalid-license.toml","bytes":1989,"sha256":"sha256:1d42945238ec458f03cfee56225894febf6d35b7398e26c5698c2746f614df29"}, diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md index 7c23ef3..c2f75b2 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md @@ -4,7 +4,7 @@ - Predecessor: `17e069a7eb3bcc6596c93bb6432984eba8472208` - Branch: `codex/capability-packs-clean-architecture-delivery-v1` - Specification commit: `5ef9707930f7095a2f57bc3e38e53bfeac06aaf2` -- Current milestone: M2 Capability Pack contract closure candidate; required local commit and controller-owned dual review pending +- Current milestone: M2 final review-fix candidate; scoped re-review pending - Completed evidence: design review READY; `git diff --check` PASS; M0 compatibility manifest and downstream facade checks independently approved at `9e689c7f04e3d8c0479c74d91669c170a4c66e52` - Terminal M0 verification: `rtk cargo fmt --check` PASS; focused manifest test PASS; full compatibility baseline PASS (9 passed, 1 ignored); `rtk git diff --check` PASS; bounded privacy scan PASS (zero matches) - M0 status: terminally closed; Spec PASS and Task quality Approved @@ -23,8 +23,10 @@ - M2 local qualification: rustfmt PASS; strict workspace Clippy PASS; capability-pack contract 20 PASS before Task 5 test; compatibility baseline 9 PASS, 1 ignored; plan CLI 11 PASS; matrix contract 16 PASS; receipt contract 10 PASS; verification contract 20 PASS, 1 ignored. The original sandbox broad run stopped at 279 PASS, 3 `Operation not permitted` denials, 1 ignored. Controller repeated the same exact broad command under narrow host permission: 487 PASS, 5 ignored, 28 suites, 7.65s. The host result is qualification evidence; sandbox denials are neither product failures nor PASS. - M2 compatibility evidence: scoped diff against `5fed7c443504969e62980141048f9279f9fa1dfe` over CLI/config/matrix/receipt/verify and v1/v2 schema surfaces was empty. The available exact compatibility manifest test `manifest_paths_and_hashes_match` passed 1/1. The brief's named `manifest_matches_the_exact_compatibility_corpus` filter selected no test (0 PASS, 10 filtered) and is not treated as evidence. - M2 closure evidence: `m2-manifest.json` schema `1.0` anchors the pre-closure HEAD and lists 18 sorted path/byte/SHA-256 entries. Its contract test observed RED because the manifest was absent, then GREEN 1/1 after creation. +- M2 final-review findings accepted for one separate fix commit: `load` had a path-metadata/reopen TOCTOU window and no explicit regular-file policy; public envelope and expansion wrappers could be mutated after validation; HTTPS authority checks accepted malformed hosts and ports; derived envelope Debug could disclose fixed-environment literals. Documentation also overstated identity, arbitrary argv safety, and provenance-digest verification. +- M2 final-review fix anchor/current HEAD: `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`; the candidate remains pending scoped specification-compliance and code-quality/security re-review. No re-review PASS is claimed here. - M2 residual boundary: inert library validation and expansion only. No official pack, CLI entry point, tool/image qualification, Docker execution, hosted exact-head result, push, PR update, merge, stable installation, tag, or release is implied. M3 must first review the `rust-deep` tool/image/license matrix and design the smallest user entry point without weakening M0 compatibility guarantees. -- Unproven: hosted CI for exact Task 5 candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. +- Unproven: scoped re-review and hosted CI for exact final-fix candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. - Heavy processes: none - External mutations: none - Restart checkpoint: requested 2026-08-30; tracked handoff and resume prompt added under `restart/2026-08-30/`; the final exact branch and external archive hashes are recorded in the persistent `RECOVERY_MANIFEST.md` diff --git a/src/capability_pack.rs b/src/capability_pack.rs index 5f7b4c0..7c50e47 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -14,7 +14,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; -use std::fs; +use std::fs::File; +use std::io::{Error, ErrorKind, Read}; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::{Path, PathBuf}; use schemars::{JsonSchema, schema_for}; @@ -68,20 +70,43 @@ impl CapabilityPackManifestV1 { } pub fn load(path: &Path) -> Result { - let metadata = fs::metadata(path).map_err(|source| CapabilityPackError::Io { + let mut file = File::open(path).map_err(|source| CapabilityPackError::Io { path: path.to_path_buf(), source, })?; - let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX); - if size > MAX_CAPABILITY_PACK_BYTES { + let metadata = file.metadata().map_err(|source| CapabilityPackError::Io { + path: path.to_path_buf(), + source, + })?; + if !metadata.file_type().is_file() { + return Err(CapabilityPackError::Io { + path: path.to_path_buf(), + source: Error::new( + ErrorKind::InvalidInput, + "capability pack must be a regular file", + ), + }); + } + let capacity = usize::try_from(metadata.len()) + .unwrap_or(MAX_CAPABILITY_PACK_BYTES + 1) + .min(MAX_CAPABILITY_PACK_BYTES + 1); + let mut bytes = Vec::with_capacity(capacity); + file.by_ref() + .take((MAX_CAPABILITY_PACK_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|source| CapabilityPackError::Io { + path: path.to_path_buf(), + source, + })?; + if bytes.len() > MAX_CAPABILITY_PACK_BYTES { return Err(CapabilityPackError::ManifestTooLarge { - actual: size, + actual: bytes.len(), maximum: MAX_CAPABILITY_PACK_BYTES, }); } - let input = fs::read_to_string(path).map_err(|source| CapabilityPackError::Io { + let input = String::from_utf8(bytes).map_err(|source| CapabilityPackError::Io { path: path.to_path_buf(), - source, + source: Error::new(ErrorKind::InvalidData, source), })?; Self::parse(&input) } @@ -133,6 +158,7 @@ impl CapabilityPackManifestV1 { }; let pack_digest = canonical_digest(&pack)?; Ok(CapabilityPackEnvelopeV1 { + expected_pack_digest: pack_digest.clone(), pack_digest, pack, profile_configs: configs, @@ -140,13 +166,27 @@ impl CapabilityPackManifestV1 { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Clone, PartialEq, Eq, Serialize)] pub struct CapabilityPackEnvelopeV1 { pub pack_digest: String, pub pack: NormalizedCapabilityPackV1, #[allow(dead_code)] #[serde(skip)] profile_configs: BTreeMap, + #[serde(skip)] + expected_pack_digest: String, +} + +impl fmt::Debug for CapabilityPackEnvelopeV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilityPackEnvelopeV1") + .field("pack_digest", &self.pack_digest) + .field("pack_id", &self.pack.pack_id) + .field("pack_version", &self.pack.pack_version) + .field("profile_count", &self.pack.profiles.len()) + .finish() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -165,6 +205,8 @@ pub struct CapabilityPackExpansionV1 { pub profile_id: String, pub evidence_class: CapabilityEvidenceClassV1, pub execution_plan: ExecutionPlanEnvelopeV1, + #[serde(skip)] + expected_canonical_bytes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -215,6 +257,7 @@ impl CapabilityPackEnvelopeV1 { &self, binding: CapabilityPackBindingV1, ) -> Result { + self.validate_public_identity()?; let raw = self .profile_configs .get(&binding.profile_id) @@ -236,7 +279,7 @@ impl CapabilityPackEnvelopeV1 { .iter() .find(|profile| profile.id == binding.profile_id) .ok_or_else(|| CapabilityPackError::UnknownProfile(binding.profile_id.clone()))?; - Ok(CapabilityPackExpansionV1 { + let mut expansion = CapabilityPackExpansionV1 { schema_version: self.pack.schema_version.clone(), pack_id: self.pack.pack_id.clone(), pack_version: self.pack.pack_version.clone(), @@ -244,14 +287,24 @@ impl CapabilityPackEnvelopeV1 { profile_id: binding.profile_id, evidence_class: profile.evidence_class, execution_plan: plan, - }) + expected_canonical_bytes: Vec::new(), + }; + expansion.expected_canonical_bytes = canonical_json(&expansion)?; + Ok(expansion) } pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { - if canonical_digest(&self.pack)? != self.pack_digest { + self.validate_public_identity()?; + Ok(canonical_json(self)?) + } + + fn validate_public_identity(&self) -> Result<(), CapabilityPackError> { + if self.pack_digest != self.expected_pack_digest + || canonical_digest(&self.pack)? != self.expected_pack_digest + { return Err(CapabilityPackError::PackDigestMismatch); } - Ok(canonical_json(self)?) + Ok(()) } pub fn inspection(&self) -> &NormalizedCapabilityPackV1 { &self.pack @@ -262,7 +315,11 @@ impl CapabilityPackExpansionV1 { pub fn canonical_bytes(&self) -> Result, CapabilityPackError> { validate_digest("pack_digest", &self.pack_digest)?; self.execution_plan.canonical_bytes()?; - Ok(canonical_json(self)?) + let actual = canonical_json(self)?; + if actual != self.expected_canonical_bytes { + return Err(CapabilityPackError::PackDigestMismatch); + } + Ok(actual) } } @@ -549,20 +606,81 @@ fn validate_digest(field: &'static str, value: &str) -> Result<(), CapabilityPac } fn validate_url(field: &'static str, value: &str) -> Result<(), CapabilityPackError> { - let authority = value - .strip_prefix("https://") - .and_then(|suffix| suffix.split('/').next()); if value.len() > 4096 || value .chars() .any(|character| character.is_whitespace() || character.is_control()) - || value.contains('#') - || authority.is_none_or(|authority| authority.is_empty() || authority.contains('@')) + || !value.starts_with("https://") { - Err(CapabilityPackError::InvalidField(field)) - } else { + return Err(CapabilityPackError::InvalidField(field)); + } + let suffix = &value["https://".len()..]; + let authority_end = suffix.find(['/', '?', '#']).unwrap_or(suffix.len()); + if validate_https_authority(&suffix[..authority_end]) { Ok(()) + } else { + Err(CapabilityPackError::InvalidField(field)) + } +} + +fn validate_https_authority(authority: &str) -> bool { + if authority.is_empty() || authority.contains('@') { + return false; + } + if let Some(host) = authority.strip_prefix('[') { + let Some((address, port)) = host.split_once(']') else { + return false; + }; + return !address.is_empty() + && address.parse::().is_ok() + && validate_optional_port(port); + } + if authority.contains(['[', ']']) { + return false; } + let (host, port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + validate_dns_or_ipv4_host(host) && port.is_none_or(validate_port) +} + +fn validate_optional_port(value: &str) -> bool { + value.strip_prefix(':').is_some_and(validate_port) +} + +fn validate_port(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok() +} + +fn validate_dns_or_ipv4_host(host: &str) -> bool { + if host.is_empty() { + return false; + } + if host + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.') + { + return host.parse::().is_ok(); + } + host.len() <= 253 + && host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + && label + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && label + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + }) } fn validate_sources( diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index 816ebe9..daea518 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -90,7 +90,7 @@ fn m2_manifest_matches_exact_file_bytes() { assert_eq!(manifest["schema_version"], "1.0"); assert_eq!( manifest["base_commit"], - "da3849b0c6b06d7992ee4c68cf5d9d6e2781425f" + "3f77c426681d3118e5e00cfeee5bb7c9c8c2b663" ); let entries = manifest["files"].as_array().expect("M2 manifest files"); let paths = entries @@ -724,7 +724,6 @@ fn validator_enforces_value_syntax_and_input_freshness() { for value in [ "http://example.com", "https://user@example.com", - "https://example.com/#fragment", "https://example.com/space here", "https://example.com/\u{0000}", ] { @@ -870,3 +869,87 @@ fn expansion_canonical_bytes_rejects_malformed_pack_digest() { )); } } + +#[test] +fn validator_rejects_malformed_https_authorities() { + for value in [ + "https://?query", + "https:///path", + "https://:443", + "https://example.com:", + "https://example.com:not-a-port", + "https://example.com:65536", + "https://example.com:80:81", + "https://bad_host.example", + "https://999.999.999.999", + "https://[::1", + "https://[]", + "https://[::1]suffix", + "https://[::1]:", + "https://[::1]:65536", + ] { + assert_invalid_field( + |manifest| manifest.upstream_sources[0].url = value.to_owned(), + "upstream_sources.url", + ); + } + let mut manifest = valid_manifest(); + manifest.upstream_sources[0].url = "https://[2001:db8::1]:443/path?query#fragment".to_owned(); + manifest.validate().expect("valid bracketed IPv6 authority"); +} + +#[test] +fn envelope_identity_seal_rejects_public_pack_mutation_before_expand() { + let mut pack = validate_fixture(VALID).expect("pack"); + pack.pack.pack_id = "other-pack".to_owned(); + assert!(matches!( + pack.expand(valid_binding()), + Err(CapabilityPackError::PackDigestMismatch) + )); +} + +#[test] +fn expansion_identity_seal_rejects_replacement_with_another_valid_plan() { + let pack = validate_fixture(VALID).expect("pack"); + let mut expansion = pack.expand(valid_binding()).expect("expansion"); + let replacement = pack + .expand(binding_for_project("other/project")) + .expect("replacement expansion"); + expansion.execution_plan = replacement.execution_plan; + assert!(matches!( + expansion.canonical_bytes(), + Err(CapabilityPackError::PackDigestMismatch) + )); +} + +#[test] +fn envelope_debug_redacts_fixed_environment_literals() { + let mut manifest = valid_manifest(); + manifest.profiles[0].environment.fixed.insert( + "CAPABILITY_PACK_TEST_TOKEN".to_owned(), + "fixed-environment-literal-must-not-appear".to_owned(), + ); + let pack = manifest.validate().expect("pack"); + assert!(!format!("{pack:?}").contains("fixed-environment-literal-must-not-appear")); +} + +#[test] +fn load_rejects_non_regular_paths_and_bounds_reads() { + let root = unique_test_root("pack-load"); + std::fs::create_dir_all(&root).expect("create owned test root"); + assert!(matches!( + CapabilityPackManifestV1::load(&root), + Err(CapabilityPackError::Io { path, source }) + if path == root && source.kind() == std::io::ErrorKind::InvalidInput + )); + let oversized = root.join("oversized.toml"); + std::fs::write(&oversized, vec![b'x'; MAX_CAPABILITY_PACK_BYTES + 1]) + .expect("write oversized manifest"); + assert!(matches!( + CapabilityPackManifestV1::load(&oversized), + Err(CapabilityPackError::ManifestTooLarge { actual, maximum }) + if actual == MAX_CAPABILITY_PACK_BYTES + 1 && maximum == MAX_CAPABILITY_PACK_BYTES + )); + std::fs::remove_file(&oversized).expect("remove oversized manifest"); + std::fs::remove_dir(&root).expect("remove empty owned test root"); +} From 4c9af6f0b220a789f30e214d8ff90996f21e1d00 Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:51:53 +0200 Subject: [PATCH 11/13] fix: close capability pack review gaps --- docs/CAPABILITY_PACKS.md | 2 +- .../m2-manifest.json | 8 ++--- .../progress.md | 6 ++-- src/capability_pack.rs | 20 +++++++++++-- tests/capability_pack_contract.rs | 30 +++++++++++++++++-- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/docs/CAPABILITY_PACKS.md b/docs/CAPABILITY_PACKS.md index 85046c4..db5fa2d 100644 --- a/docs/CAPABILITY_PACKS.md +++ b/docs/CAPABILITY_PACKS.md @@ -5,7 +5,7 @@ Status: schema and inert library inspection/expansion only; no official pack exe ## TOML schema 1.0 Manifests use `schema_version = "1.0"` and strict TOML fields. A manifest is at -most 1 MiB; it has one identity (`pack_id`, `pack_version`, `license`, and +most 1 MiB; it has manifest metadata (`pack_id`, `pack_version`, `license`, and `description`), upstream sources, and 1–32 profiles. Each profile has a unique identifier, bounded metadata, tools (at most 32), inputs (at most 64), hosts, targets, runtime, environment, caches, storage, and checks. Lists are bounded diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json index 6730766..0509274 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/m2-manifest.json @@ -1,13 +1,13 @@ { "schema_version": "1.0", - "base_commit": "3f77c426681d3118e5e00cfeee5bb7c9c8c2b663", + "base_commit": "2e6286cc23584d5e82842aacf106c3bb5e7462df", "files": [ {"path":"CHANGELOG.md","bytes":20296,"sha256":"sha256:bf3cd42627ff40e23490d0cf4a36d640e5c08feaef2af4d1779172464722b54c"}, - {"path":"docs/CAPABILITY_PACKS.md","bytes":3226,"sha256":"sha256:cfb1dedb6b0793b7ffce2a8012469d673e2c823b0dbfd494d4d22495d81ae212"}, + {"path":"docs/CAPABILITY_PACKS.md","bytes":3231,"sha256":"sha256:434c95d26a9fb75916037391cc5322095ccfd660155d315464735815d8fc079b"}, {"path":"schema/capability-pack-v1.schema.json","bytes":11325,"sha256":"sha256:2c35b17807ef85a4ba79343c3d1948fd185ca7d5f1dc9a6b1dbf15b9635b8f09"}, - {"path":"src/capability_pack.rs","bytes":35182,"sha256":"sha256:d6bcb6fe88fe68448f6be6425a55af2914da477f1bab209862a1193f3d03b59f"}, + {"path":"src/capability_pack.rs","bytes":35849,"sha256":"sha256:3b395715921b15732727c3762fc2d43b965184c1087dde9db948b6ce745ade0c"}, {"path":"src/lib.rs","bytes":1036,"sha256":"sha256:55f5edc35ac49ff0dbcb10723986c3da239b23788a757c232d14071e8c20644b"}, - {"path":"tests/capability_pack_contract.rs","bytes":33594,"sha256":"sha256:a23ffd9bd5ae2caa51c97a6ad971cde09abb1ca7700fd9b23243f1019d698329"}, + {"path":"tests/capability_pack_contract.rs","bytes":34412,"sha256":"sha256:5e90941506da053d2fd61e1a14f852e25d57fe057fbf87cea9a96d6685a8ecce"}, {"path":"tests/fixtures/capability-pack-v1/dependency-cycle.toml","bytes":2067,"sha256":"sha256:aa88ff2c9931d23ddba372f045bc402bad514085fa13a3f159dbf78172030e3d"}, {"path":"tests/fixtures/capability-pack-v1/invalid-image.toml","bytes":1910,"sha256":"sha256:64cbf330a32076072587b0498e8248284e8c67ba0370b35b0a278e980360f7ab"}, {"path":"tests/fixtures/capability-pack-v1/invalid-license.toml","bytes":1989,"sha256":"sha256:1d42945238ec458f03cfee56225894febf6d35b7398e26c5698c2746f614df29"}, diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md index c2f75b2..423998d 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md @@ -4,7 +4,7 @@ - Predecessor: `17e069a7eb3bcc6596c93bb6432984eba8472208` - Branch: `codex/capability-packs-clean-architecture-delivery-v1` - Specification commit: `5ef9707930f7095a2f57bc3e38e53bfeac06aaf2` -- Current milestone: M2 final review-fix candidate; scoped re-review pending +- Current milestone: M2 final review-fix round-2 candidate; scoped round-2 re-review pending - Completed evidence: design review READY; `git diff --check` PASS; M0 compatibility manifest and downstream facade checks independently approved at `9e689c7f04e3d8c0479c74d91669c170a4c66e52` - Terminal M0 verification: `rtk cargo fmt --check` PASS; focused manifest test PASS; full compatibility baseline PASS (9 passed, 1 ignored); `rtk git diff --check` PASS; bounded privacy scan PASS (zero matches) - M0 status: terminally closed; Spec PASS and Task quality Approved @@ -25,8 +25,10 @@ - M2 closure evidence: `m2-manifest.json` schema `1.0` anchors the pre-closure HEAD and lists 18 sorted path/byte/SHA-256 entries. Its contract test observed RED because the manifest was absent, then GREEN 1/1 after creation. - M2 final-review findings accepted for one separate fix commit: `load` had a path-metadata/reopen TOCTOU window and no explicit regular-file policy; public envelope and expansion wrappers could be mutated after validation; HTTPS authority checks accepted malformed hosts and ports; derived envelope Debug could disclose fixed-environment literals. Documentation also overstated identity, arbitrary argv safety, and provenance-digest verification. - M2 final-review fix anchor/current HEAD: `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`; the candidate remains pending scoped specification-compliance and code-quality/security re-review. No re-review PASS is claimed here. +- M2 final-review round 1 re-review found three remaining findings: documentation still called manifest metadata an identity; the strict HTTPS change rejected a valid no-port bracketed IPv6 authority while accepting fragments; and expansion retained derived Debug that could disclose fixed-environment literals. Round 2 corrects only those findings. The FIFO/nonblocking-open residual is explicitly out of scope pending separate portable policy design. +- M2 final-review round-2 fix anchor/current HEAD: `2e6286cc23584d5e82842aacf106c3bb5e7462df`; scoped round-2 specification-compliance and code-quality/security re-review remains pending. No PASS is claimed. - M2 residual boundary: inert library validation and expansion only. No official pack, CLI entry point, tool/image qualification, Docker execution, hosted exact-head result, push, PR update, merge, stable installation, tag, or release is implied. M3 must first review the `rust-deep` tool/image/license matrix and design the smallest user entry point without weakening M0 compatibility guarantees. -- Unproven: scoped re-review and hosted CI for exact final-fix candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. +- Unproven: scoped round-2 re-review and hosted CI for exact final-fix candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. - Heavy processes: none - External mutations: none - Restart checkpoint: requested 2026-08-30; tracked handoff and resume prompt added under `restart/2026-08-30/`; the final exact branch and external archive hashes are recorded in the persistent `RECOVERY_MANIFEST.md` diff --git a/src/capability_pack.rs b/src/capability_pack.rs index 7c50e47..742cb65 100644 --- a/src/capability_pack.rs +++ b/src/capability_pack.rs @@ -196,7 +196,7 @@ pub struct CapabilityPackBindingV1 { pub receipt: ReceiptConfig, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Clone, PartialEq, Eq, Serialize)] pub struct CapabilityPackExpansionV1 { pub schema_version: String, pub pack_id: String, @@ -209,6 +209,21 @@ pub struct CapabilityPackExpansionV1 { expected_canonical_bytes: Vec, } +impl fmt::Debug for CapabilityPackExpansionV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilityPackExpansionV1") + .field("schema_version", &self.schema_version) + .field("pack_id", &self.pack_id) + .field("pack_version", &self.pack_version) + .field("pack_digest", &self.pack_digest) + .field("profile_id", &self.profile_id) + .field("evidence_class", &self.evidence_class) + .field("execution_plan_digest", &self.execution_plan.plan_digest) + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct NormalizedCapabilityPackV1 { @@ -610,6 +625,7 @@ fn validate_url(field: &'static str, value: &str) -> Result<(), CapabilityPackEr || value .chars() .any(|character| character.is_whitespace() || character.is_control()) + || value.contains('#') || !value.starts_with("https://") { return Err(CapabilityPackError::InvalidField(field)); @@ -646,7 +662,7 @@ fn validate_https_authority(authority: &str) -> bool { } fn validate_optional_port(value: &str) -> bool { - value.strip_prefix(':').is_some_and(validate_port) + value.is_empty() || value.strip_prefix(':').is_some_and(validate_port) } fn validate_port(value: &str) -> bool { diff --git a/tests/capability_pack_contract.rs b/tests/capability_pack_contract.rs index daea518..36b8128 100644 --- a/tests/capability_pack_contract.rs +++ b/tests/capability_pack_contract.rs @@ -90,7 +90,7 @@ fn m2_manifest_matches_exact_file_bytes() { assert_eq!(manifest["schema_version"], "1.0"); assert_eq!( manifest["base_commit"], - "3f77c426681d3118e5e00cfeee5bb7c9c8c2b663" + "2e6286cc23584d5e82842aacf106c3bb5e7462df" ); let entries = manifest["files"].as_array().expect("M2 manifest files"); let paths = entries @@ -887,6 +887,7 @@ fn validator_rejects_malformed_https_authorities() { "https://[::1]suffix", "https://[::1]:", "https://[::1]:65536", + "https://example.com/#fragment", ] { assert_invalid_field( |manifest| manifest.upstream_sources[0].url = value.to_owned(), @@ -894,8 +895,15 @@ fn validator_rejects_malformed_https_authorities() { ); } let mut manifest = valid_manifest(); - manifest.upstream_sources[0].url = "https://[2001:db8::1]:443/path?query#fragment".to_owned(); - manifest.validate().expect("valid bracketed IPv6 authority"); + manifest.upstream_sources[0].url = "https://[2001:db8::1]:443/path?query".to_owned(); + manifest + .validate() + .expect("valid bracketed IPv6 authority with port"); + let mut manifest = valid_manifest(); + manifest.upstream_sources[0].url = "https://[::1]".to_owned(); + manifest + .validate() + .expect("valid bracketed IPv6 authority without port"); } #[test] @@ -933,6 +941,22 @@ fn envelope_debug_redacts_fixed_environment_literals() { assert!(!format!("{pack:?}").contains("fixed-environment-literal-must-not-appear")); } +#[test] +fn expansion_debug_redacts_fixed_environment_literals() { + let mut manifest = valid_manifest(); + manifest.profiles[0].environment.fixed.insert( + "CAPABILITY_PACK_EXPANSION_TEST_TOKEN".to_owned(), + "expansion-fixed-environment-literal-must-not-appear".to_owned(), + ); + let expansion = manifest + .validate() + .and_then(|pack| pack.expand(valid_binding())) + .expect("expansion"); + assert!( + !format!("{expansion:?}").contains("expansion-fixed-environment-literal-must-not-appear") + ); +} + #[test] fn load_rejects_non_regular_paths_and_bounds_reads() { let root = unique_test_root("pack-load"); From 6018319a331f09e2731a0f44195e197b96e31abd Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:55:33 +0200 Subject: [PATCH 12/13] docs: record capability pack review acceptance --- .../task-5-review-record-report.md | 26 +++++++++++++++++++ .../progress.md | 22 ++++++++-------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 .superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md diff --git a/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md b/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md new file mode 100644 index 0000000..72d5a93 --- /dev/null +++ b/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md @@ -0,0 +1,26 @@ +# Task 5 Review Record + +## Scope + +Documentation-only update to `docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md`. No manifest or implementation files were changed. + +## Recorded state + +- M2 is locally complete. +- Dual final reviews and both scoped round-2 re-reviews are recorded as Approved. +- Accepted implementation commits: `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`, `2e6286cc23584d5e82842aacf106c3bb5e7462df`, `4c9af6f0b220a789f30e214d8ff90996f21e1d00`. +- Final-review Important findings and both fix rounds are recorded resolved; no remaining Critical/Important findings. +- The `487 PASS, 5 ignored` broad result is explicitly bounded to the earlier candidate; final exact-head host rerun remains pending controller verification. +- FIFO/nonblocking-open is recorded as a future portable-policy residual, not an M2 blocker. +- Hosted exact-head CI, push, and draft PR remain unproven and are the next external gate. + +## Verification + +- `rtk git diff --check` — PASS. +- Bounded privacy scan of `progress.md` for local paths and common secret markers — zero matches. +- No tests run; prose-only change. + +## Commit + +Commit message: `docs: record capability pack review acceptance` + diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md index 423998d..c52fd0d 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md @@ -2,9 +2,9 @@ - Base: `5fed7c443504969e62980141048f9279f9fa1dfe` - Predecessor: `17e069a7eb3bcc6596c93bb6432984eba8472208` -- Branch: `codex/capability-packs-clean-architecture-delivery-v1` +- Branch: `codex/capability-pack-contract-v1` - Specification commit: `5ef9707930f7095a2f57bc3e38e53bfeac06aaf2` -- Current milestone: M2 final review-fix round-2 candidate; scoped round-2 re-review pending +- Current milestone: M2 locally complete; dual final reviews and both scoped round-2 re-reviews Approved - Completed evidence: design review READY; `git diff --check` PASS; M0 compatibility manifest and downstream facade checks independently approved at `9e689c7f04e3d8c0479c74d91669c170a4c66e52` - Terminal M0 verification: `rtk cargo fmt --check` PASS; focused manifest test PASS; full compatibility baseline PASS (9 passed, 1 ignored); `rtk git diff --check` PASS; bounded privacy scan PASS (zero matches) - M0 status: terminally closed; Spec PASS and Task quality Approved @@ -17,19 +17,19 @@ - M2 plan commit: `a1c922d4f47fd06e74a336b89a19aecc703908bd` - M2 plan decision: TOML-only schema `1.0`; library-first inert validation/inspection; explicit one-profile binding into the existing schema-`1.3` plan; separate pack and plan digests; no CLI, receipt, policy, execution, or publication change in M2 - M2 plan review: initial NOT_READY findings for implicit types/helpers and missing error mappings were corrected; final Luna re-review READY with no Critical or Important findings -- M2 accepted implementation commits: Task 1 `1cb0f03ea83b6b3bfd53319345d16d909ec0e3fe`, review fix `324476e6d53c4b727ccbc41cff7884ec0c6d37f6`; Task 2 `d4d6f5fb4f9612c1f4a0f1ada4235731e3a55380`, review fixes `b5cc2ba272adbda2f148e8b05d61064a11361377` and `7962c2ab2139444bb3dfffc9848582478078b22a`; Task 3 `58e8820ed38b08b9583dd9ca3d411e1f8d582219`, review fix `b0cddc02cd534f295cc45c6c506aeea13d0f171`; Task 4 `da3849b0c6b06d7992ee4c68cf5d9d6e2781425f`. -- M2 accepted reviews: Task 1 scoped re-review PASS after one Important fix; Task 2 scoped re-reviews PASS after two shell-parser fixes, with one parked Minor duplicate-ID assertion concern; Task 3 scoped re-review PASS after its exact-digest validation fix; Task 4 independent review PASS with no Critical, Important, or Minor findings. Task 5 dual review remains pending and is not claimed. -- M2 pre-closure anchor/current HEAD: `da3849b0c6b06d7992ee4c68cf5d9d6e2781425f` (`docs: define capability pack contract`); before the required Task 5 commit, the candidate worktree is dirty only in Task 5-owned closure files. -- M2 local qualification: rustfmt PASS; strict workspace Clippy PASS; capability-pack contract 20 PASS before Task 5 test; compatibility baseline 9 PASS, 1 ignored; plan CLI 11 PASS; matrix contract 16 PASS; receipt contract 10 PASS; verification contract 20 PASS, 1 ignored. The original sandbox broad run stopped at 279 PASS, 3 `Operation not permitted` denials, 1 ignored. Controller repeated the same exact broad command under narrow host permission: 487 PASS, 5 ignored, 28 suites, 7.65s. The host result is qualification evidence; sandbox denials are neither product failures nor PASS. +- M2 accepted implementation commits: closure `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`, hardening `2e6286cc23584d5e82842aacf106c3bb5e7462df`, final gap fix `4c9af6f0b220a789f30e214d8ff90996f21e1d00`. +- M2 accepted reviews: dual final reviews and both scoped round-2 re-reviews Approved. Final-review Important findings and both fix rounds are resolved; no remaining Critical or Important findings. +- M2 implementation history includes the pre-closure anchor `da3849b0c6b06d7992ee4c68cf5d9d6e2781425f` (`docs: define capability pack contract`); accepted closure and hardening follow below. +- M2 local qualification: rustfmt PASS; strict workspace Clippy PASS; capability-pack contract 20 PASS before Task 5 test; compatibility baseline 9 PASS, 1 ignored; plan CLI 11 PASS; matrix contract 16 PASS; receipt contract 10 PASS; verification contract 20 PASS, 1 ignored. The original sandbox broad run stopped at 279 PASS, 3 `Operation not permitted` denials, 1 ignored. Controller repeated the same exact broad command under narrow host permission on the earlier candidate: 487 PASS, 5 ignored, 28 suites, 7.65s. The host result is qualification evidence for that candidate; final exact-head host rerun remains pending controller verification. Sandbox denials are neither product failures nor PASS. - M2 compatibility evidence: scoped diff against `5fed7c443504969e62980141048f9279f9fa1dfe` over CLI/config/matrix/receipt/verify and v1/v2 schema surfaces was empty. The available exact compatibility manifest test `manifest_paths_and_hashes_match` passed 1/1. The brief's named `manifest_matches_the_exact_compatibility_corpus` filter selected no test (0 PASS, 10 filtered) and is not treated as evidence. - M2 closure evidence: `m2-manifest.json` schema `1.0` anchors the pre-closure HEAD and lists 18 sorted path/byte/SHA-256 entries. Its contract test observed RED because the manifest was absent, then GREEN 1/1 after creation. - M2 final-review findings accepted for one separate fix commit: `load` had a path-metadata/reopen TOCTOU window and no explicit regular-file policy; public envelope and expansion wrappers could be mutated after validation; HTTPS authority checks accepted malformed hosts and ports; derived envelope Debug could disclose fixed-environment literals. Documentation also overstated identity, arbitrary argv safety, and provenance-digest verification. -- M2 final-review fix anchor/current HEAD: `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`; the candidate remains pending scoped specification-compliance and code-quality/security re-review. No re-review PASS is claimed here. +- M2 final-review fix anchor: `3f77c426681d3118e5e00cfeee5bb7c9c8c2b663`; its scoped specification-compliance and code-quality/security re-review is accepted below. - M2 final-review round 1 re-review found three remaining findings: documentation still called manifest metadata an identity; the strict HTTPS change rejected a valid no-port bracketed IPv6 authority while accepting fragments; and expansion retained derived Debug that could disclose fixed-environment literals. Round 2 corrects only those findings. The FIFO/nonblocking-open residual is explicitly out of scope pending separate portable policy design. -- M2 final-review round-2 fix anchor/current HEAD: `2e6286cc23584d5e82842aacf106c3bb5e7462df`; scoped round-2 specification-compliance and code-quality/security re-review remains pending. No PASS is claimed. +- M2 final-review round-2 fix anchor: `2e6286cc23584d5e82842aacf106c3bb5e7462df`; final gap fix/current implementation HEAD before this review-record commit: `4c9af6f0b220a789f30e214d8ff90996f21e1d00`. +- FIFO/nonblocking-open remains an explicit future portable-policy residual, not an M2 blocker. - M2 residual boundary: inert library validation and expansion only. No official pack, CLI entry point, tool/image qualification, Docker execution, hosted exact-head result, push, PR update, merge, stable installation, tag, or release is implied. M3 must first review the `rust-deep` tool/image/license matrix and design the smallest user entry point without weakening M0 compatibility guarantees. -- Unproven: scoped round-2 re-review and hosted CI for exact final-fix candidate head, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. +- Unproven: final exact-head host rerun remains pending controller verification; hosted exact-head CI, push, draft PR, publication, release gates, and reference packs remain unproven. No external mutation occurred. - Heavy processes: none - External mutations: none -- Restart checkpoint: requested 2026-08-30; tracked handoff and resume prompt added under `restart/2026-08-30/`; the final exact branch and external archive hashes are recorded in the persistent `RECOVERY_MANIFEST.md` -- Next action after restart: complete the handoff's read-only audit, then obtain an exact bounded authorization to fetch `origin`, push the unchanged branch non-forced, open a draft PR, and require terminal hosted CI before dispatching M2 Task 1 +- Next action: fresh final local verification, then exact non-force push/draft PR/hosted CI authorization. From e5c6a80589a8ca7190b81c55ca8dc2346bf848fc Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 31 Aug 2026 02:57:16 +0200 Subject: [PATCH 13/13] docs: record capability pack qualification --- .../task-5-review-record-report.md | 3 +++ .../progress.md | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md b/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md index 72d5a93..4e93cf4 100644 --- a/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md +++ b/.superpowers/sdd/2026-08-30-m2-capability-pack-contract/task-5-review-record-report.md @@ -24,3 +24,6 @@ Documentation-only update to `docs/superpowers/programmes/2026-08-30-capability- Commit message: `docs: record capability pack review acceptance` +## Follow-up qualification record + +At implementation/review-record anchor `6018319a331f09e2731a0f44195e197b96e31abd`, controller checks recorded fmt PASS, strict workspace Clippy PASS, capability contract 27/27 PASS, compatibility manifest 1/1 PASS, M2 manifest 1/1 PASS, empty scoped compatibility diff, diff-check PASS, and host full suite 494 passed/5 ignored/28 suites/10.09s. The next commit is documentation-only evidence recording. diff --git a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md index c52fd0d..c6e681b 100644 --- a/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md +++ b/docs/superpowers/programmes/2026-08-30-capability-packs-clean-architecture/progress.md @@ -29,7 +29,9 @@ - M2 final-review round-2 fix anchor: `2e6286cc23584d5e82842aacf106c3bb5e7462df`; final gap fix/current implementation HEAD before this review-record commit: `4c9af6f0b220a789f30e214d8ff90996f21e1d00`. - FIFO/nonblocking-open remains an explicit future portable-policy residual, not an M2 blocker. - M2 residual boundary: inert library validation and expansion only. No official pack, CLI entry point, tool/image qualification, Docker execution, hosted exact-head result, push, PR update, merge, stable installation, tag, or release is implied. M3 must first review the `rust-deep` tool/image/license matrix and design the smallest user entry point without weakening M0 compatibility guarantees. -- Unproven: final exact-head host rerun remains pending controller verification; hosted exact-head CI, push, draft PR, publication, release gates, and reference packs remain unproven. No external mutation occurred. +- M2 final local qualification at `6018319a331f09e2731a0f44195e197b96e31abd`: fmt PASS; strict workspace Clippy PASS; capability contract 27/27 PASS; compatibility manifest 1/1 PASS; M2 manifest 1/1 PASS; scoped compatibility diff empty; diff-check PASS; host full suite 494 passed, 5 ignored, 28 suites, 10.09s. Worktree was clean before this documentation update. +- `6018319a331f09e2731a0f44195e197b96e31abd` is the locally qualified implementation/review-record anchor. This follow-up is documentation-only evidence recording; it makes no source, schema, or manifest change. +- Unproven: hosted exact-head CI, push, draft PR, publication, release gates, and reference packs. No external mutation occurred. - Heavy processes: none - External mutations: none -- Next action: fresh final local verification, then exact non-force push/draft PR/hosted CI authorization. +- Next action: exact non-force push/draft PR/hosted CI authorization.