diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 809baf10..38366376 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -302,15 +302,17 @@ Internal (fleet): yet bundled and no release claim is made; packaging and the remaining collectors stay tracked in issue #198. -- **2026-08-31 — Apple Container trial qualified with a containment caveat:** +- **2026-09-03 — Apple Container architecture and mount boundary qualified:** Installed the signed/notarized 1.3.1 CLI after owner authorization and exercised a 1-CPU/256-MB, internal-network, no-DNS, read-only-root sandbox on the supported arm64 macOS 27 host. The cached no-op run took 0.61 seconds and - teardown left zero containers. A controlled traversal fixture proved the CLI - does not enforce CodeVetter's workspace-root boundary, so any adapter must - canonicalize and reject out-of-root mounts itself. This is external-prerequisite - qualification, not a bundled dependency or architecture approval; issue #197 - remains open. + teardown left zero containers; idle services held about 17.2 MiB RSS at 0.0% + CPU across three samples. The selected first adapter is the external CLI, + which adds no app-bundle or FFI dependency. A tested Rust mount planner now + canonicalizes and revalidates source identity and rejects traversal, symlink, + replacement, syntax-injection, and malformed-target cases. The supervised + runner, network attestation, and real-workload qualification remain + claim-closed; this is not yet a shipped runtime-isolation capability. - **2026-08-24 — Unified local change check (unreleased source):** the packaged `codevetter` CLI now accepts one clean checked-out PR head or Git range plus diff --git a/apps/desktop/src-tauri/src/capabilities.rs b/apps/desktop/src-tauri/src/capabilities.rs index c8983987..f8f04576 100644 --- a/apps/desktop/src-tauri/src/capabilities.rs +++ b/apps/desktop/src-tauri/src/capabilities.rs @@ -738,11 +738,14 @@ pub fn capability_registry() -> CapabilityRegistry { projection(Availability::Planned, Authority::None, &[]), projection(Availability::Planned, Authority::None, &[]), ), - &[tool("Apple Containerization or measured alternative", "Candidate containment boundary", "not selected")], - "Not yet defined; no isolation claim is made.", - Qualification::Unqualified, - &["No production isolation backend has passed the runtime and compatibility gates."], - "Benchmark candidates against real CodeVetter workloads before selecting a dependency.", + &[tool("Apple container CLI 1.3.1", "Selected external-prerequisite isolation backend with a CodeVetter-owned mount planner", "qualified candidate; runner not shipped")], + "Only an app-owned immutable worktree may be mounted, using a canonical read-only source under the selected root. No runtime-isolation claim is made yet.", + Qualification::Partial, + &[ + "The supervised runner, exact local-image preflight, internal-network attestation, bounded output, cancellation, and real-workload regression evidence remain open.", + "The CLI string interface retains a final TOCTOU window and is not approved for concurrently mutable host roots.", + ], + "Complete the claim-closed runner contract and qualify it against real verification workloads before exposing this capability.", ), ], }; diff --git a/apps/desktop/src-tauri/src/commands/apple_container.rs b/apps/desktop/src-tauri/src/commands/apple_container.rs new file mode 100644 index 00000000..bc0e3bfb --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/apple_container.rs @@ -0,0 +1,267 @@ +//! Policy boundary for the external Apple `container` sandbox candidate. +//! +//! This module deliberately does not launch the CLI. It produces the only +//! bind-mount argument a future runner may pass and revalidates the source +//! identity immediately before process creation. Runtime supervision and +//! isolated-network attestation remain separate gates. + +use std::fs::Metadata; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleContainerMountPlan { + allowed_root: PathBuf, + canonical_source: PathBuf, + container_target: String, + source_identity: SourceIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SourceIdentity { + is_directory: bool, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl AppleContainerMountPlan { + /// Creates a read-only bind plan contained by `allowed_root`. + pub fn new( + allowed_root: &Path, + requested_source: &Path, + container_target: &str, + ) -> Result { + let allowed_root = canonical_directory(allowed_root, "allowed workspace root")?; + let canonical_source = requested_source.canonicalize().map_err(|error| { + format!( + "Apple Container mount source {} is unavailable: {error}", + requested_source.display() + ) + })?; + if !canonical_source.starts_with(&allowed_root) { + return Err("Apple Container mount source escapes the allowed workspace root".into()); + } + + let source_text = safe_mount_path(&canonical_source, "source")?; + validate_container_target(container_target)?; + let metadata = canonical_source.metadata().map_err(|error| { + format!( + "Could not inspect Apple Container mount source {}: {error}", + canonical_source.display() + ) + })?; + + // Materialize now so unsupported mount-string characters fail before + // a plan can be retained or handed to a runner. + let _ = format!("type=bind,source={source_text},target={container_target},readonly"); + + Ok(Self { + allowed_root, + canonical_source, + container_target: container_target.to_string(), + source_identity: SourceIdentity::from_metadata(&metadata), + }) + } + + pub fn canonical_source(&self) -> &Path { + &self.canonical_source + } + + /// Re-checks both containment and filesystem identity. A future runner + /// must call this immediately before spawning `container`. + pub fn revalidate(&self) -> Result<(), String> { + let current_root = canonical_directory(&self.allowed_root, "allowed workspace root")?; + if current_root != self.allowed_root { + return Err("Apple Container allowed workspace root identity changed".into()); + } + let current_source = self.canonical_source.canonicalize().map_err(|error| { + format!( + "Apple Container mount source {} is unavailable: {error}", + self.canonical_source.display() + ) + })?; + if current_source != self.canonical_source || !current_source.starts_with(¤t_root) { + return Err("Apple Container mount source identity or containment changed".into()); + } + let current_identity = SourceIdentity::from_metadata( + ¤t_source + .metadata() + .map_err(|error| format!("Could not revalidate mount source: {error}"))?, + ); + if current_identity != self.source_identity { + return Err("Apple Container mount source identity changed".into()); + } + Ok(()) + } + + pub fn read_only_mount_argument(&self) -> Result { + self.revalidate()?; + Ok(format!( + "type=bind,source={},target={},readonly", + safe_mount_path(&self.canonical_source, "source")?, + self.container_target + )) + } +} + +impl SourceIdentity { + fn from_metadata(metadata: &Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + Self { + is_directory: metadata.is_dir(), + #[cfg(unix)] + device: metadata.dev(), + #[cfg(unix)] + inode: metadata.ino(), + } + } +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + let canonical = path.canonicalize().map_err(|error| { + format!( + "Apple Container {label} {} is unavailable: {error}", + path.display() + ) + })?; + if !canonical.is_dir() { + return Err(format!("Apple Container {label} must be a directory")); + } + Ok(canonical) +} + +fn safe_mount_path<'a>(path: &'a Path, label: &str) -> Result<&'a str, String> { + let value = path + .to_str() + .ok_or_else(|| format!("Apple Container mount {label} must be valid UTF-8"))?; + if value.contains(',') || value.chars().any(char::is_control) { + return Err(format!( + "Apple Container mount {label} contains unsupported mount syntax" + )); + } + Ok(value) +} + +fn validate_container_target(target: &str) -> Result<(), String> { + if target.is_empty() + || target.contains(',') + || target.chars().any(char::is_control) + || !Path::new(target).is_absolute() + || Path::new(target).components().any(|component| { + matches!( + component, + Component::ParentDir | Component::CurDir | Component::Prefix(_) + ) + }) + { + return Err("Apple Container mount target must be a normalized absolute path".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "codevetter-apple-container-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(root.join("workspace/nested")).expect("fixture"); + std::fs::create_dir_all(root.join("sibling")).expect("sibling"); + root + } + + #[test] + fn produces_only_a_canonical_read_only_bind() { + let fixture = fixture(); + let workspace = fixture.join("workspace"); + let plan = AppleContainerMountPlan::new( + &workspace, + &workspace.join("nested/../nested"), + "/workspace", + ) + .expect("contained plan"); + + let argument = plan.read_only_mount_argument().expect("argument"); + assert!(argument.starts_with("type=bind,source=")); + assert!(argument.ends_with(",target=/workspace,readonly")); + assert!(!argument.contains("/../")); + assert_eq!( + plan.canonical_source(), + workspace.join("nested").canonicalize().unwrap() + ); + + std::fs::remove_dir_all(fixture).expect("cleanup"); + } + + #[test] + fn rejects_parent_traversal_outside_the_allowed_root() { + let fixture = fixture(); + let error = AppleContainerMountPlan::new( + &fixture.join("workspace"), + &fixture.join("workspace/../sibling"), + "/workspace", + ) + .expect_err("escape must fail"); + assert!(error.contains("escapes")); + std::fs::remove_dir_all(fixture).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn rejects_a_symlink_that_resolves_outside_the_allowed_root() { + use std::os::unix::fs::symlink; + + let fixture = fixture(); + symlink(fixture.join("sibling"), fixture.join("workspace/link")).expect("symlink"); + let error = AppleContainerMountPlan::new( + &fixture.join("workspace"), + &fixture.join("workspace/link"), + "/workspace", + ) + .expect_err("symlink escape must fail"); + assert!(error.contains("escapes")); + std::fs::remove_dir_all(fixture).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn revalidation_rejects_source_replacement_before_launch() { + use std::os::unix::fs::symlink; + + let fixture = fixture(); + let workspace = fixture.join("workspace"); + let source = workspace.join("nested"); + let plan = AppleContainerMountPlan::new(&workspace, &source, "/workspace").expect("plan"); + std::fs::rename(&source, workspace.join("original")).expect("rename"); + symlink(fixture.join("sibling"), &source).expect("replacement symlink"); + + let error = plan + .read_only_mount_argument() + .expect_err("replacement must fail"); + assert!(error.contains("identity or containment changed")); + std::fs::remove_dir_all(fixture).expect("cleanup"); + } + + #[test] + fn rejects_mount_string_injection_and_relative_targets() { + let fixture = fixture(); + let workspace = fixture.join("workspace"); + let comma_source = workspace.join("comma,source"); + std::fs::create_dir(&comma_source).expect("comma fixture"); + + assert!(AppleContainerMountPlan::new(&workspace, &comma_source, "/workspace").is_err()); + assert!(AppleContainerMountPlan::new(&workspace, &workspace, "workspace").is_err()); + assert!(AppleContainerMountPlan::new(&workspace, &workspace, "/work/../escape").is_err()); + assert!( + AppleContainerMountPlan::new(&workspace, &workspace, "/work,target=/escape").is_err() + ); + + std::fs::remove_dir_all(fixture).expect("cleanup"); + } +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index ee8e79dd..1dcec16d 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod agent; pub mod agent_memories; pub mod agent_stream; pub mod agent_terminal; +pub mod apple_container; pub mod audience_validation; pub mod blast_radius; pub mod business_rule_archaeology; diff --git a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json index a4694a3d..f953705b 100644 --- a/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json +++ b/apps/macos/CodeVetterPackage/Sources/CodeVetterFeature/Resources/capabilities.v1.json @@ -912,17 +912,18 @@ }, "underlying_tools": [ { - "name": "Apple Containerization or measured alternative", - "role": "Candidate containment boundary", - "requirement": "not selected" + "name": "Apple container CLI 1.3.1", + "role": "Selected external-prerequisite isolation backend with a CodeVetter-owned mount planner", + "requirement": "qualified candidate; runner not shipped" } ], - "data_boundary": "Not yet defined; no isolation claim is made.", - "qualification": "unqualified", + "data_boundary": "Only an app-owned immutable worktree may be mounted, using a canonical read-only source under the selected root. No runtime-isolation claim is made yet.", + "qualification": "partial", "limitations": [ - "No production isolation backend has passed the runtime and compatibility gates." + "The supervised runner, exact local-image preflight, internal-network attestation, bounded output, cancellation, and real-workload regression evidence remain open.", + "The CLI string interface retains a final TOCTOU window and is not approved for concurrently mutable host roots." ], - "next_step": "Benchmark candidates against real CodeVetter workloads before selecting a dependency." + "next_step": "Complete the claim-closed runner contract and qualify it against real verification workloads before exposing this capability." } ] } diff --git a/docs/knowledge/tooling-decisions.md b/docs/knowledge/tooling-decisions.md index eac1c960..257a5d4c 100644 --- a/docs/knowledge/tooling-decisions.md +++ b/docs/knowledge/tooling-decisions.md @@ -71,7 +71,7 @@ as a signed desktop sidecar remains **approved** work. | OSV-Scanner 2.5.1 | Repository runner wired | `pnpm quality:vulnerabilities` produces fail-closed offline SARIF plus npm/crates.io/Go/SwiftURL database identities; the remediated baseline retains 20 result instances across 19 visible advisory IDs, tracked in issue #195 | | StrykerJS 10.0.0 | Bounded local command wired | Accounting oracle: 208 mutants, 197 killed, 11 diagnostic/equivalent survivors, 94.71% score, and a 90% ratchet; the faster TAP runner was rejected because it left 66 mutants uncovered, tracked in issue #196 | | Schemathesis 4.25.2 | Rejected for current surface | CLI availability verified, but CodeVetter has no OpenAPI/Swagger contract or HTTP server to exercise | -| Apple `container` CLI 1.3.1 | Trialled with containment defect | The signed/notarized service passed bounded no-network execution and cleanup, but accepted a controlled `..` sibling mount; any product adapter must canonicalize every mount under the selected root, tracked in issue #197 | +| Apple `container` CLI 1.3.1 | Qualified external prerequisite; adapter building | The signed/notarized service passed bounded no-network execution and cleanup; CodeVetter's tested Rust mount planner now rejects traversal, escaping symlinks, source replacement, and mount-string injection before producing a read-only bind. The supervised product runner remains claim-closed | | Lighthouse CI 0.15.1 | Trialled, rejected as a repo dependency | Three local landing-page runs passed the proposed category/Core Web Vitals gates, but the package introduced three high advisories including unpatched `extract-zip` traversal; raw Lighthouse JSON ingestion remains supported | | Size Limit 13.0.3 | Wired, additive | Caps the complete emitted desktop JS distribution after the existing Tauri-aware entry/Home and per-chunk budget gate; it does not replace those product-specific calculations | | `fast-xml-parser` 5.11.1 | Wired | Closed JUnit and Cobertura XML ingestion with DTD/entity rejection before parsing | diff --git a/docs/knowledge/tooling-sandboxing.md b/docs/knowledge/tooling-sandboxing.md index beee82d2..995325de 100644 --- a/docs/knowledge/tooling-sandboxing.md +++ b/docs/knowledge/tooling-sandboxing.md @@ -27,15 +27,29 @@ and memory, an internal no-DNS network, dropped capabilities, host-environment absence, and teardown all behaved as expected. The first image/init-image run took 20.56 seconds and the cached images occupied 1.45 GB. -One contract failed: the CLI accepted a controlled bind source containing `..` +One native contract failed: the CLI accepted a controlled bind source containing `..` when it resolved outside the intended fixture root. CodeVetter must canonicalize and enforce workspace containment itself; Apple Container's mount validation is -not that policy. The [qualification receipt](https://github.com/Codevetter/codevetter/blob/main/evidence/verification/apple-container-qualification-2026-08-31.md) -records identities, measurements, teardown, and remaining gates. Issue #197 -keeps the architecture decision open. - -If the measured contract is sound, choose between consuming Apple's -Containerization Swift package through a sidecar and embedding `libkrun`. +not that policy. The Rust mount planner now rejects traversal, escaping +symlinks, source replacement, mount-string injection, and malformed guest +targets, then revalidates the source identity immediately before returning a +read-only bind argument. The [qualification receipt](https://github.com/Codevetter/codevetter/blob/main/evidence/verification/apple-container-qualification-2026-08-31.md) +records identities, measurements, teardown, and the remaining runner gates. + +The selected first adapter is the external Apple CLI on supported Macs. It is +already signed and measured, keeps the app bundle free of a VMM/FFI dependency, +and does not add nested signing work. Installation and service startup remain +explicit owner actions. A product runner must still prove exact-version and +local-image preflight, an attested internal network, minimal environment, +timeout/cancellation, bounded output, and cleanup before this becomes a shipped +isolation claim. + +The string-based CLI retains a final TOCTOU window after source revalidation. +That boundary is acceptable only for CodeVetter-owned immutable worktrees that +the untrusted guest cannot mutate before launch. If concurrently mutable host +roots become a requirement, move to Apple's Containerization Swift package and +an audited descriptor-based mount path. Keep `libkrun` as the fallback if real +workloads disprove the first-party path, rather than taking on FFI now. **`libkrun`** (Apache-2.0, `containers/libkrun`, 2,643★) is a small VMM **library** written in Rust and built on Apple's `Hypervisor.framework`. It is diff --git a/evidence/verification/apple-container-qualification-2026-08-31.md b/evidence/verification/apple-container-qualification-2026-08-31.md index c947b003..c6286bb7 100644 --- a/evidence/verification/apple-container-qualification-2026-08-31.md +++ b/evidence/verification/apple-container-qualification-2026-08-31.md @@ -37,6 +37,9 @@ service start separately downloaded and verified the 664.3 MB default kernel. and `/root` failed with a read-only-filesystem error. - `--rm` teardown left zero containers and zero local volumes. The cached image and init image occupied 1.45 GB and were intentionally retained. +- With no containers running, three two-second samples measured the external + service processes at 17,568 KiB resident in total and 0.0% CPU: 7,424 KiB + API server, 7,920 KiB core-image plugin, and 2,224 KiB network plugin. The trial network was deleted after use. No host credential, home, SSH, cloud, or production path was mounted or inspected. @@ -51,10 +54,31 @@ reject sources outside the allowed root before process launch, pass the canonical source to the CLI, and cover symlink and time-of-check/time-of-use cases. The product must not treat the CLI's mount validation as containment. -## Decision +## Adapter policy and decision -Keep Apple Container as the measured external-prerequisite candidate, not a -bundled dependency. Its isolation controls and warm-start result are promising, -but path containment must be owned by CodeVetter. Issue #197 remains open for -the CLI-versus-Containerization-versus-libkrun architecture comparison, idle -resource measurement, signing/notarization analysis, and an adapter design. +Use the Apple CLI as the first external-prerequisite adapter on supported Macs. +It already provides the measured isolation contract with a 0.61-second cached +start and about 17.2 MiB of idle resident service memory, while adding no app +bundle, nested-code-signing, or Rust FFI dependency. Do not bundle it or start +its system service silently; installation remains an explicit owner action. + +`commands/apple_container.rs` now owns a pure mount-policy boundary. It +canonicalizes the allowed root and source, rejects component-level escapes, +symlink escapes, unsupported mount-string characters, and non-normalized guest +targets, records the source filesystem identity, and revalidates identity and +containment immediately before returning the read-only CLI argument. Five +fixture tests cover the accepted path and each observed or anticipated failure. + +The CLI string interface still has an irreducible final time-of-check/time-of-use +window after revalidation. That is acceptable only for an app-owned, immutable +worktree whose host permissions exclude the untrusted guest before launch. If +CodeVetter later permits concurrently mutable host roots, move to Apple's +Containerization library with an audited descriptor-based mount path; do not +pretend another string check removes the race. `libkrun` remains a fallback only +if real workloads disprove the first-party CLI's performance or compatibility. + +This receipt qualifies the architecture and mount boundary, not a shipped +runtime-isolation claim. A product runner still needs an exact CLI/version and +local-image preflight, attested internal network, minimal environment, +timeout/cancellation, bounded output, teardown, and real-workload regression +evidence before the capability can become available.