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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src-tauri/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
),
],
};
Expand Down
267 changes: 267 additions & 0 deletions apps/desktop/src-tauri/src/commands/apple_container.rs
Original file line number Diff line number Diff line change
@@ -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<Self, String> {
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(&current_root) {
return Err("Apple Container mount source identity or containment changed".into());
}
let current_identity = SourceIdentity::from_metadata(
&current_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<String, String> {
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<PathBuf, String> {
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");
}
}
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
2 changes: 1 addition & 1 deletion docs/knowledge/tooling-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
28 changes: 21 additions & 7 deletions docs/knowledge/tooling-sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading