Skip to main content

kranz_engine/
sandbox_container.rs

1//! Tier-3 container sandbox provider — run a worker/validator session inside
2//! a container with the declared write/egress policy. See
3//! docs/scoping/worker-sandboxing.md tier 3.
4//!
5//! Two network postures for `enforce = "fs+net"`, chosen by the egress list.
6//! An EMPTY `egress` list runs `--network none` — a hard egress boundary on
7//! the live-proven Linux host path. Note the honest tradeoff: `none`
8//! also blocks the agent's API egress, so it suits offline gates/validation.
9//! A NON-EMPTY `egress` list runs the worker on a unique Docker `--internal`
10//! network. A trusted dual-homed relay is the only other container on that
11//! network; it injects a run-secret authorization header before forwarding
12//! CONNECT to the host-side filtering proxy (`crate::egress_proxy`). The
13//! worker never receives that credential and has no default route, so
14//! ignoring the proxy env cannot bypass the per-host filter. See
15//! `crate::container_egress` for provisioning, teardown, and stale-resource
16//! recovery. Runtimes other than Docker refuse this posture before spawn.
17//! API-driven workers that need
18//! no egress list use `fs` (runtime default bridge/NAT, the same
19//! permissiveness as the tier-2 fs tier).
20//!
21//! Host support is evidence-gated, not a platform allowlist. Linux is
22//! supported unconditionally: CI renews a receipt for the shipped bind-mount,
23//! authority-mask, and egress contracts on every run. Windows is refused
24//! unconditionally, because the shipped contract uses POSIX guest paths,
25//! Linux images, and `/dev/null` authority masks that Windows containers do
26//! not honor; macOS keeps the process provider's native Seatbelt boundary as
27//! its default. Anything else must PROVE the mount contract on the host, at
28//! run time, via [`prove_bind_mount`] — hosted macOS cannot renew a CI
29//! receipt (its runners are guests without the virtualization a VM-backed
30//! runtime needs), but a developer's own Mac can answer the same question
31//! about itself in about a second.
32//!
33//! Runtime detection is not that evidence, and neither is a `-v` flag the
34//! runtime accepted. A daemon that cannot see the host path creates an empty
35//! directory inside its VM, mounts that, and exits 0, so the declared write
36//! set silently does not exist. See [`MountProof`].
37//!
38//! Write policy: the container's root filesystem is read-only; the writable
39//! set is exactly the declared mounts — `session_cwd` (rw), `mission_dir`
40//! (ro, so the engine-owned audit log / state / control inbox / transcripts
41//! stay read-only inside the container even when the mission dir sits under
42//! an rw-mounted `session_cwd`), the session-private scratch `tmpdir` (rw,
43//! also `HOME`/`TMPDIR` inside the container — NOT the shared system temp
44//! root, which would expose sibling missions' worktrees), and each
45//! `extra_write` entry (rw). Everything else is denied by the
46//! runtime, the container analogue of the tier-2 write allowlist.
47//!
48//! Worker image: the default `DEFAULT_IMAGE` proves the isolation boundary
49//! but cannot run an agent. A production worker image needs the agent CLI +
50//! Node on PATH plus the mission toolchain — the same layering the repo's
51//! `Dockerfile` comment block spells out for the M6 cloud image (see the
52//! "What this image intentionally does NOT bundle" section there).
53//!
54//! Engine-run gates (ticket container-gate-wrapper): the same `run --rm -i
55//! --read-only` shape also executes validation/final/merge gate commands
56//! inside the mission container — see [`container_gate_run_args`] for the
57//! gate-specific deltas (named container for timeout teardown, the gate's
58//! sanitized env forwarded via `-e`, and a toolchain posture that mounts the
59//! rustup toolchain + npm cache read-only but NEVER the real Cargo root:
60//! the gate's `CARGO_HOME` is a seeded cache-only home precisely because the
61//! real one is a credential directory).
62
63use std::collections::HashMap;
64use std::path::{Path, PathBuf};
65use std::sync::{Mutex, OnceLock};
66
67use crate::sandbox::SandboxInputs;
68
69/// Image used when the role config does not name one. Minimal and
70/// pullable on the supported Linux container path; production use
71/// should set `sandbox.image`.
72pub const DEFAULT_IMAGE: &str = "alpine:3";
73
74/// Container runtimes kranz knows how to drive, in PATH preference order.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ContainerRuntime {
77    Docker,
78    Podman,
79    Nerdctl,
80    /// Apple's `container` CLI (github.com/apple/container). Last in
81    /// preference; its argv is the docker-compatible common denominator.
82    AppleContainer,
83}
84
85impl ContainerRuntime {
86    /// All runtimes in detection preference order.
87    const PREFERENCE_ORDER: &'static [ContainerRuntime] = &[
88        ContainerRuntime::Docker,
89        ContainerRuntime::Podman,
90        ContainerRuntime::Nerdctl,
91        ContainerRuntime::AppleContainer,
92    ];
93
94    /// The executable name resolved on PATH and spawned for `run`.
95    pub fn binary(self) -> &'static str {
96        match self {
97            ContainerRuntime::Docker => "docker",
98            ContainerRuntime::Podman => "podman",
99            ContainerRuntime::Nerdctl => "nerdctl",
100            ContainerRuntime::AppleContainer => "container",
101        }
102    }
103
104    /// Host-side client configuration, separate from the environment forwarded
105    /// into the container. A scratch HOME hides Docker/Colima contexts; copying
106    /// the worker environment here can also retarget the daemon during teardown.
107    pub(crate) fn client_env(self) -> std::collections::HashMap<String, String> {
108        let mut keys = vec![
109            "PATH",
110            "HOME",
111            "USER",
112            "LOGNAME",
113            "LANG",
114            "LC_ALL",
115            "LC_CTYPE",
116            "TMPDIR",
117            "XDG_CONFIG_HOME",
118            "XDG_RUNTIME_DIR",
119            "SSH_AUTH_SOCK",
120            "USERPROFILE",
121            "SystemRoot",
122            "ComSpec",
123            "APPDATA",
124            "LOCALAPPDATA",
125            "TEMP",
126            "TMP",
127        ];
128        match self {
129            Self::Docker => keys.extend([
130                "DOCKER_HOST",
131                "DOCKER_CONTEXT",
132                "DOCKER_CONFIG",
133                "DOCKER_TLS",
134                "DOCKER_TLS_VERIFY",
135                "DOCKER_CERT_PATH",
136                "DOCKER_API_VERSION",
137            ]),
138            Self::Podman => {
139                keys.extend(["CONTAINER_HOST", "CONTAINER_CONNECTION", "CONTAINER_SSHKEY"])
140            }
141            Self::Nerdctl => {
142                keys.extend(["CONTAINERD_ADDRESS", "CONTAINERD_NAMESPACE", "NERDCTL_TOML"])
143            }
144            Self::AppleContainer => {}
145        }
146        keys.into_iter()
147            .filter_map(|key| {
148                std::env::var(key)
149                    .ok()
150                    .map(|value| (key.to_string(), value))
151            })
152            .collect()
153    }
154}
155
156/// Detect the preferred available container runtime on this host's PATH.
157pub fn detect() -> Option<ContainerRuntime> {
158    detect_with(crate::sandbox::command_available)
159}
160
161/// Whether this host can actually honor the shipped container contract, as
162/// opposed to merely having a runtime binary on PATH.
163///
164/// Detection answers "is there a runtime?"; this answers "is its host contract
165/// supported?". They diverge by platform, and for different reasons.
166///
167/// Linux is supported unconditionally: CI renews a receipt for the shipped
168/// bind-mount, authority-mask, and egress contracts on every run.
169///
170/// Windows is refused unconditionally. It may have `docker.exe`, but the
171/// shipped contract uses POSIX guest paths, Linux images, and `/dev/null`
172/// authority masks that Windows containers do not honor. This was masked
173/// until `command_available` learned to consult `PATHEXT`; before that
174/// `detect()` never saw `docker.exe` and the Windows container tests took
175/// their silent skip path and reported `ok` without running.
176///
177/// macOS is supported exactly when THIS host proves it. Hosted runners cannot
178/// renew a CI receipt, because they are already guests without the
179/// virtualization a VM-backed runtime needs, so the evidence has to come from
180/// the host at run time instead of from a lane that cannot execute. The proof
181/// is a real bind-mount round trip over the paths a session mounts, which is
182/// what separates a working developer machine from one whose runtime accepts
183/// `-v` and shares nothing.
184pub fn host_supports_container_contract() -> bool {
185    if cfg!(target_os = "linux") {
186        return true;
187    }
188    if cfg!(target_os = "windows") {
189        return false;
190    }
191    let Some(runtime) = detect() else {
192        return false;
193    };
194    matches!(host_mount_contract_proof(runtime), MountProof::Proven)
195}
196
197/// The proof behind [`host_supports_container_contract`], over the roots a
198/// test or session actually mounts: the working tree and the system temp
199/// root. Sharing is per path, so proving one says nothing about the other.
200pub fn host_mount_contract_proof(runtime: ContainerRuntime) -> MountProof {
201    let cwd = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
202    for root in [cwd.as_path(), std::env::temp_dir().as_path()] {
203        match cached_bind_mount_proof(runtime, root, DEFAULT_IMAGE) {
204            MountProof::Proven => {}
205            failed => return failed,
206        }
207    }
208    MountProof::Proven
209}
210
211/// Guest path the bind-mount proof mounts its probe directory at.
212pub const MOUNT_PROOF_GUEST_DIR: &str = "/kranz-mount-proof";
213
214#[cfg(any(target_os = "macos", target_os = "linux"))]
215mod mount_proof;
216
217/// Whether this host's runtime actually shares a bind-mounted directory with
218/// the container, as opposed to accepting the `-v` flag and sharing nothing.
219///
220/// A runtime that cannot see the host path does NOT fail. Docker creates an
221/// empty directory inside its VM, mounts that, and exits 0. The declared
222/// write set then silently does not exist: a worker writes into a VM that is
223/// destroyed at teardown, and the validator judges a tree where nothing
224/// landed. Nothing in the run reports an error.
225///
226/// Measured on an M4 Pro (2026-08-25) with Colima 0.10.3 and Docker 29.2.1.
227/// Colima's default mount set is the home directory alone, macOS puts
228/// `TMPDIR` under `/var/folders`, and a probe file written on the host before
229/// the run was invisible inside the container with exit code 0 throughout.
230/// The same hazard reaches any host whose daemon does not share its
231/// filesystem: Docker Desktop's file-sharing list, a remote `DOCKER_HOST`, a
232/// rootless daemon in its own mount namespace.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum MountProof {
235    /// A sentinel written on the host was read inside the container, and a
236    /// sentinel written inside the container was read back on the host.
237    Proven,
238    /// The round trip did not close. Carries the operator-facing reason.
239    Failed(String),
240}
241
242/// The probe argv: mount `host_dir` rw, read the host's sentinel from inside,
243/// and write the guest's sentinel back out. One container run proves both
244/// directions, because a mount can be visible one way and stale the other.
245/// This renders the sentinel command; use [`prove_bind_mount`] for owned,
246/// bounded execution and confirmed daemon cleanup.
247pub fn mount_proof_argv(host_dir: &Path, image: &str, guest_sentinel: &str) -> Vec<String> {
248    vec![
249        "run".to_string(),
250        "--rm".to_string(),
251        "-v".to_string(),
252        format!("{}:{MOUNT_PROOF_GUEST_DIR}", container_host_path(host_dir)),
253        image.to_string(),
254        "sh".to_string(),
255        "-c".to_string(),
256        mount_proof_script(guest_sentinel),
257    ]
258}
259
260fn mount_proof_script(guest_sentinel: &str) -> String {
261    // A missing sentinel is a finding, not a shell error: distinguish an
262    // unshared mount from a failed daemon so the operator gets the right remedy.
263    format!(
264        "if [ -r {MOUNT_PROOF_GUEST_DIR}/host.txt ]; then cat {MOUNT_PROOF_GUEST_DIR}/host.txt; \
265             else printf %s no-host-sentinel; fi; \
266             printf %s {guest_sentinel} > {MOUNT_PROOF_GUEST_DIR}/guest.txt 2>/dev/null || true"
267    )
268}
269
270/// Run the round trip under `host_dir` and report whether the mount is real.
271///
272/// `host_dir` must be the directory the mission will actually mount under,
273/// not a convenient one. The failure is path-dependent: on a default Colima
274/// a probe under `$HOME` passes while the same probe under `TMPDIR` shares
275/// nothing, so proving the wrong path proves nothing.
276pub fn prove_bind_mount(runtime: ContainerRuntime, host_dir: &Path, image: &str) -> MountProof {
277    #[cfg(any(target_os = "macos", target_os = "linux"))]
278    if runtime == ContainerRuntime::Docker {
279        return mount_proof::prove(host_dir, image);
280    }
281    MountProof::Failed(format!(
282        "{} bind-mount proof refused before spawn: owned helper cleanup is supported only \
283         with Docker on Linux/macOS (path {}, image {image})",
284        runtime.binary(),
285        host_dir.display()
286    ))
287}
288
289/// The message an operator can act on. Naming the path matters more than
290/// naming the runtime, because the fix is almost always to share that path
291/// or to move the mission's scratch under one the runtime already shares.
292#[cfg(any(target_os = "macos", target_os = "linux"))]
293fn unshared_path_reason(runtime: ContainerRuntime, host_dir: &Path, symptom: &str) -> String {
294    let mut reason = format!(
295        "{} accepted a bind mount of {} and shared nothing: {symptom}. \
296         The runtime's daemon cannot see this host path, so the declared write set would \
297         not exist inside the container and a worker's output would be lost silently. \
298         Share this path with the runtime (Colima mounts only the home directory by \
299         default: `colima start --mount {}:w`; Docker Desktop keeps its own file-sharing \
300         list)",
301        runtime.binary(),
302        host_dir.display(),
303        host_dir.display()
304    );
305    // The scratch root has a second remedy the others do not: kranz chose
306    // that path, so the operator can move it instead of reconfiguring a VM.
307    if host_dir == crate::backend_claude::scratch_root_base() {
308        reason.push_str(&format!(
309            ", or move kranz's own scratch to a directory the runtime already shares by \
310             setting {}=<path> (this root is scratch, not your workspace)",
311            crate::backend_claude::SCRATCH_ROOT_ENV
312        ));
313    } else {
314        reason.push_str(" or point the mission's workspace at a path it already shares");
315    }
316    reason
317}
318
319/// One proof per (runtime, path) for the life of the process.
320///
321/// The probe costs a container run. Session resolution happens per role and
322/// per feature, so proving every time would add that cost to every spawn,
323/// and the answer cannot change while a daemon keeps running.
324fn proof_cache() -> &'static Mutex<HashMap<(String, String), MountProof>> {
325    static CACHE: OnceLock<Mutex<HashMap<(String, String), MountProof>>> = OnceLock::new();
326    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
327}
328
329/// [`prove_bind_mount`] memoized per runtime and path.
330pub fn cached_bind_mount_proof(
331    runtime: ContainerRuntime,
332    host_dir: &Path,
333    image: &str,
334) -> MountProof {
335    let key = (
336        runtime.binary().to_string(),
337        host_dir.to_string_lossy().into_owned(),
338    );
339    if let Ok(cache) = proof_cache().lock() {
340        if let Some(proof) = cache.get(&key) {
341            return proof.clone();
342        }
343    }
344    let proof = prove_bind_mount(runtime, host_dir, image);
345    if let Ok(mut cache) = proof_cache().lock() {
346        cache.insert(key, proof.clone());
347    }
348    proof
349}
350
351/// Prove every distinct host root a run will mount.
352///
353/// One probe is not enough. Sharing is per path on every runtime that has
354/// this hazard, so a host can share the checkout and not the scratch: the
355/// exact shape of the 2026-08-25 macOS failure, where the worktree under
356/// `$HOME` mounted fine and `TMPDIR` under `/var/folders` did not. Proving
357/// only the convenient root would reproduce the original bug with extra
358/// ceremony, so every declared root is proven and the FIRST failure is
359/// returned, naming the path the operator has to fix.
360///
361/// Roots are deduplicated by their proof cache key, so the common case of
362/// several mounts under one shared root costs one container run.
363pub fn prove_mount_roots(runtime: ContainerRuntime, roots: &[PathBuf], image: &str) -> MountProof {
364    let mut seen = Vec::new();
365    for root in roots {
366        if root.as_os_str().is_empty() || seen.iter().any(|prior| prior == root) {
367            continue;
368        }
369        seen.push(root.clone());
370        match cached_bind_mount_proof(runtime, root, image) {
371            MountProof::Proven => {}
372            failed => return failed,
373        }
374    }
375    MountProof::Proven
376}
377
378/// The roots a session or gate actually mounts, in the order the operator
379/// would want them reported.
380///
381/// The checkout contributes its PARENT rather than the working tree itself:
382/// the tree is a git worktree, and a directory appearing and vanishing inside
383/// it can race a concurrent `git status` in a mission that cares about a
384/// clean tree. The system temp root stands in for the per-session scratch,
385/// which does not exist yet at resolution time but is created underneath it.
386pub fn declared_mount_roots(
387    session_cwd: &Path,
388    mission_dir: &Path,
389    extra_write: &[PathBuf],
390) -> Vec<PathBuf> {
391    let mut roots = vec![
392        session_cwd.parent().unwrap_or(session_cwd).to_path_buf(),
393        mission_dir.to_path_buf(),
394        // The scratch BASE, not the system temp dir: an operator who pointed
395        // scratch somewhere the runtime shares must have that path proven,
396        // and proving the temp dir they no longer use would refuse a mission
397        // that works.
398        crate::backend_claude::scratch_root_base(),
399    ];
400    roots.extend(extra_write.iter().cloned());
401    roots
402}
403
404/// Why a live container test is skipping, in the host's own terms.
405///
406/// "Supported only on Linux" was true when the platform list was the whole
407/// answer. Now a macOS host can qualify, so a skip has to say which fact
408/// disqualified this one: no runtime at all, or a runtime whose mounts do
409/// not round trip.
410pub fn container_contract_skip_detail() -> String {
411    if cfg!(target_os = "windows") {
412        return "the container provider refuses Windows: POSIX guest paths, Linux images, \
413                and /dev/null authority masks are not honored there"
414            .to_string();
415    }
416    match detect() {
417        None => "no docker/podman/nerdctl/container on PATH".to_string(),
418        Some(runtime) => match host_mount_contract_proof(runtime) {
419            MountProof::Proven => {
420                "the host contract is supported; this skip should not have fired".to_string()
421            }
422            MountProof::Failed(reason) => reason,
423        },
424    }
425}
426
427/// Detection with an injectable PATH lookup so tests control availability.
428pub fn detect_with(lookup: impl Fn(&str) -> bool) -> Option<ContainerRuntime> {
429    ContainerRuntime::PREFERENCE_ORDER
430        .iter()
431        .copied()
432        .find(|runtime| lookup(runtime.binary()))
433}
434
435/// The resolved container to run a session in: which runtime, which image.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct ContainerSpec {
438    pub runtime: ContainerRuntime,
439    pub image: String,
440    /// Unique internal network provisioned for one `fs+net` session with a
441    /// non-empty egress list. `None` for every other posture. The runner sets
442    /// this only after the relay and authenticated host proxy are ready.
443    pub network: Option<String>,
444    /// Daemon-owned worker container name paired with `network`. Naming lets
445    /// boundary teardown force-remove the worker after a killed runtime
446    /// client or timeout; `None` for postures without the per-run boundary.
447    pub name: Option<String>,
448}
449
450/// Build the `<runtime> run` argv (excluding the runtime binary itself) for
451/// running `binary args` under the resolved container sandbox.
452///
453/// Network: `fs+net` with an empty egress list maps to `--network none` (the
454/// hard boundary); `fs+net` with a non-empty egress list joins the unique
455/// internal network provisioned in `ContainerSpec::network` and forwards the
456/// trusted relay endpoint into the container env. If either value is absent,
457/// the builder falls back to `--network none`: a wiring bug bricks egress
458/// rather than silently reopening the runtime bridge. `fs` passes no network
459/// flag, keeping the
460/// runtime's default bridge/NAT — the same permissiveness as the tier-2 fs
461/// tier.
462/// One mount spec `host:host[:ro]` — the single format both the builder and
463/// the tests use (POSIX and Windows path forms differ; tests derive
464/// expectations through this helper rather than hardcoding POSIX literals).
465fn mount_arg(host_abs: &str, read_only: bool) -> String {
466    format!(
467        "{host_abs}:{host_abs}{}",
468        if read_only { ":ro" } else { "" }
469    )
470}
471
472/// The host spelling a `-v` spec may carry.
473///
474/// Mount specs are colon-delimited, and a Windows VERBATIM path
475/// (`\\?\C:\...`) makes the runtime's parser count too many colons:
476///
477/// ```text
478/// docker: invalid spec: \\?\C:\...:\\?\C:\...: too many colons
479/// ```
480///
481/// [`crate::sandbox::absolutize`] canonicalizes, and Windows canonicalization
482/// ALWAYS returns the verbatim form, so every container mount on Windows hit
483/// this. Strip the prefix exactly as `GitRepo::git_path_arg` does for git.
484/// A verbatim UNC path (`\\?\UNC\server\share`) is left untouched — it has no
485/// plain DOS spelling to fall back to.
486fn container_host_path(path: &Path) -> String {
487    let absolute = crate::sandbox::absolutize(path);
488    let rendered = absolute.as_os_str().to_string_lossy();
489    #[cfg(windows)]
490    if let Some(rest) = rendered.strip_prefix(r"\\?\") {
491        if !rest.starts_with("UNC") {
492            return rest.to_string();
493        }
494    }
495    rendered.into_owned()
496}
497
498/// Process-count bound for a worker/gate container. Generous next to the
499/// relay's 64 (a `cargo build -j` or an `npm ci` legitimately forks wide)
500/// but finite: without it a fork bomb inside the container takes the HOST
501/// down, since the container shares the host's pid resources.
502const CONTAINER_PIDS_LIMIT: &str = "512";
503
504/// `run --rm -i --read-only` plus the hardening the egress relay already
505/// gets — the shared prologue: the writable set is exactly the declared
506/// mounts; everything else is denied by the runtime.
507///
508/// Cross-tier drift closed (2026-09-01 adversarial audit, MED-2): the relay
509/// and its loader run `--user`, `--cap-drop ALL`,
510/// `--security-opt no-new-privileges` and `--pids-limit`
511/// (`crate::container_egress`), while the worker and gate containers ran
512/// with none of them. That left the agent as uid 0 inside the container
513/// with Docker's default capability set — `CAP_DAC_OVERRIDE`, `CAP_CHOWN`,
514/// `CAP_FOWNER`, `CAP_SETUID`, `CAP_MKNOD`, `CAP_NET_RAW` — writing into
515/// bind mounts that land at the IDENTICAL host path, so container-root
516/// writes appeared in the operator's tree as uid 0 and a setuid-root binary
517/// could be planted in a host-visible directory.
518///
519/// `--user` maps to the OWNER of the session cwd (the same derivation
520/// `container_egress::credential_owner` applies to the relay's credential
521/// dir), so writes through the rw mounts land as the operator, not root.
522/// Unix only: there is no uid/gid to map on other hosts, and the container
523/// provider already refuses Windows outright.
524fn run_prologue(inputs: &SandboxInputs) -> Vec<String> {
525    let mut out = vec![
526        "run".to_string(),
527        "--rm".to_string(),
528        "-i".to_string(),
529        "--read-only".to_string(),
530        "--cap-drop".to_string(),
531        "ALL".to_string(),
532        "--security-opt".to_string(),
533        "no-new-privileges".to_string(),
534        "--pids-limit".to_string(),
535        CONTAINER_PIDS_LIMIT.to_string(),
536    ];
537    if let Some(owner) = crate::container_egress::mount_owner(&inputs.session_cwd) {
538        out.push("--user".to_string());
539        out.push(owner);
540    }
541    // Docker config can inject proxy credentials into containers automatically.
542    // Only the explicit sandbox/contract environment may supply these values.
543    for key in [
544        "HTTP_PROXY",
545        "HTTPS_PROXY",
546        "FTP_PROXY",
547        "ALL_PROXY",
548        "NO_PROXY",
549        "http_proxy",
550        "https_proxy",
551        "ftp_proxy",
552        "all_proxy",
553        "no_proxy",
554    ] {
555        out.extend(["-e".to_string(), format!("{key}=")]);
556    }
557    out
558}
559
560/// The declared write/audit mount set: `session_cwd` (rw), `mission_dir`
561/// (ro — the engine writes mission metadata from outside the sandbox, and
562/// this ro mount stacks over the rw session_cwd mount when checkout mode
563/// makes the mission dir its descendant — the container analogue of the
564/// tier-2 mission-metadata write deny), the session-private scratch `tmpdir`
565/// (rw, also `HOME`/`TMPDIR` inside the container — NOT the shared system
566/// temp root, which would expose sibling missions' worktrees), and each
567/// `extra_write` entry (rw). Deduplicated, `session_cwd` first so it is the
568/// working directory's own mount; nested mounts stack deepest-last.
569fn push_policy_mounts(out: &mut Vec<String>, inputs: &SandboxInputs) {
570    let mut mounts: Vec<(String, bool)> = Vec::new();
571    let denied_dirs: Vec<_> = crate::sandbox::authority_read_deny_dirs(inputs)
572        .iter()
573        .map(|path| crate::sandbox::absolutize(path))
574        .collect();
575    let denied_files: Vec<_> = crate::sandbox::authority_read_deny_paths(inputs)
576        .iter()
577        .map(|path| crate::sandbox::absolutize(path))
578        .collect();
579    let mut add_mount = |path: &Path, ro: bool| {
580        let path = crate::sandbox::absolutize(path);
581        // Docker's nested binds win over an enclosing tmpfs. extraWrite
582        // must never reopen the operator authority directory.
583        if denied_dirs.iter().any(|dir| path.starts_with(dir))
584            || denied_files.iter().any(|file| path.starts_with(file))
585        {
586            return;
587        }
588        let host = container_host_path(&path);
589        if !mounts.iter().any(|(existing, _)| existing == &host) {
590            mounts.push((host, ro));
591        }
592    };
593    add_mount(&inputs.session_cwd, false);
594    if let Some(missions) = inputs
595        .mission_dir
596        .parent()
597        .filter(|path| path.ends_with("missions") && path.is_dir())
598    {
599        // Checkout-mode workers must not write other missions' inboxes or
600        // audit logs through the broad session mount.
601        add_mount(missions, true);
602    }
603    add_mount(&inputs.mission_dir, true);
604    add_mount(&inputs.tmpdir, false);
605    for extra in &inputs.extra_write {
606        if inputs
607            .mission_dir
608            .parent()
609            .filter(|p| p.ends_with("missions"))
610            .is_some_and(|missions| {
611                crate::sandbox::absolutize(extra).starts_with(crate::sandbox::absolutize(missions))
612            })
613        {
614            continue;
615        }
616        add_mount(extra, false);
617    }
618    for (host, ro) in mounts {
619        out.push("-v".to_string());
620        out.push(mount_arg(&host, ro));
621    }
622}
623
624/// Whether `path` lies under one of the WRITABLE mounts
625/// [`push_policy_mounts`] declares (`session_cwd`, the scratch `tmpdir`, each
626/// `extra_write`). Only those can carry a host write out of the container, so
627/// only those need a write-deny bind stacked over them — and binding anything
628/// else would newly EXPOSE a path the container could not otherwise reach
629/// (follow-up review, L-11).
630fn under_writable_mount(path: &Path, inputs: &SandboxInputs) -> bool {
631    let candidate = crate::sandbox::absolutize(path);
632    std::iter::once(&inputs.session_cwd)
633        .chain(std::iter::once(&inputs.tmpdir))
634        .chain(inputs.extra_write.iter())
635        .any(|root| candidate.starts_with(crate::sandbox::absolutize(root)))
636}
637
638/// Replace mounted authority directories with private read-only views that
639/// exclude credentials, including files created or replaced after launch.
640/// Keep engine-owned policy and Git metadata readable but immutable.
641fn push_authority_masks(out: &mut Vec<String>, inputs: &SandboxInputs) {
642    // Pin writable metadata directory nodes before authority views. A
643    // worktree parent can cover its session's .kranz directory; later masks
644    // must remain the last word there. Preserve any explicit policy mount,
645    // including read-only mounts, and never duplicate a Docker destination.
646    for node in crate::sandbox::git_metadata_mount_nodes(inputs) {
647        let node = container_host_path(&node);
648        if !out.windows(2).any(|pair| {
649            pair[0] == "-v"
650                && (pair[1] == mount_arg(&node, false) || pair[1] == mount_arg(&node, true))
651        }) {
652            out.extend(["-v".to_string(), mount_arg(&node, false)]);
653        }
654    }
655    let masks: Vec<_> = crate::sandbox::authority_directory_masks(inputs)
656        .into_iter()
657        .filter(|mask| {
658            mask.path.ancestors().any(|ancestor| {
659                let path = container_host_path(ancestor);
660                out.windows(2).any(|pair| {
661                    pair[0] == "-v"
662                        && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
663                })
664            })
665        })
666        .collect();
667    let masked_paths: std::collections::BTreeSet<_> =
668        masks.iter().map(|mask| mask.path.clone()).collect();
669    // Do not expose an otherwise-unmounted host directory just to hide its
670    // secrets. Gate containers, in particular, only mount Cargo's bin/.
671    // Replace direct mounts of each masked directory. Docker rejects two
672    // mounts at one destination, and a nested bind would reopen a shadow.
673    let mut filtered = Vec::new();
674    let mut index = 0;
675    while index < out.len() {
676        if out[index] == "-v" && index + 1 < out.len() {
677            let mount = &out[index + 1];
678            if masks.iter().any(|mask| {
679                let path = container_host_path(&mask.path);
680                mount == &mount_arg(&path, false) || mount == &mount_arg(&path, true)
681            }) {
682                index += 2;
683                continue;
684            }
685        }
686        filtered.push(out[index].clone());
687        index += 1;
688    }
689    *out = filtered;
690    for mask in &masks {
691        out.push("--tmpfs".to_string());
692        out.push(format!(
693            "{}:ro,noexec,nosuid,nodev,mode=755",
694            container_host_path(&mask.path)
695        ));
696        for path in &mask.visible_entries {
697            // A deeper private view owns this mountpoint. Rebinding its
698            // host directory here would duplicate the destination in Docker.
699            if masked_paths.contains(path) {
700                continue;
701            }
702            let path = container_host_path(path);
703            // Keep an existing narrower policy mount (e.g. missions ro or
704            // session scratch rw); all other visible entries are read-only.
705            if !out.windows(2).any(|pair| {
706                pair[0] == "-v"
707                    && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
708            }) {
709                out.push("-v".to_string());
710                out.push(mount_arg(&path, true));
711            }
712        }
713    }
714
715    // These paths remain readable but immutable. Never stack a host bind
716    // over a private authority view, which would restore hidden content.
717    let writes = crate::sandbox::authority_write_denies(inputs);
718    let git = crate::sandbox::git_metadata_write_denies(inputs);
719    for path in writes
720        .files
721        .iter()
722        .chain(writes.dirs.iter())
723        .chain(git.files.iter().filter(|path| path.is_file()))
724        .chain(git.dirs.iter())
725    {
726        if !under_writable_mount(path, inputs)
727            || path.is_symlink()
728            || !path.exists()
729            || masks
730                .iter()
731                .any(|mask| crate::sandbox::absolutize(path).starts_with(&mask.path))
732        {
733            continue;
734        }
735        let host = container_host_path(path);
736        if !out
737            .windows(2)
738            .any(|pair| pair[0] == "-v" && pair[1] == mount_arg(&host, true))
739        {
740            out.extend(["-v".to_string(), mount_arg(&host, true)]);
741        }
742    }
743}
744
745/// The working directory (the session/gate cwd itself) plus the scratch
746/// env: the session-private scratch doubles as the container's HOME/TMPDIR,
747/// so it is mounted at the identical host path and named in the env.
748fn push_workdir_and_scratch_env(out: &mut Vec<String>, inputs: &SandboxInputs) {
749    // `-w` and the HOME/TMPDIR values must name the SAME spelling the mounts
750    // used, or the working directory and scratch env point at paths the
751    // runtime never mounted.
752    out.push("-w".to_string());
753    out.push(container_host_path(&inputs.session_cwd));
754    let scratch = container_host_path(&inputs.tmpdir);
755    out.push("-e".to_string());
756    out.push(format!("HOME={scratch}"));
757    out.push("-e".to_string());
758    out.push(format!("TMPDIR={scratch}"));
759}
760
761/// Which toolchain-cache posture [`push_toolchain_caches`] mounts.
762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763enum ToolchainMount {
764    /// Agent sessions: the CACHE SUBDIRS of the Cargo home cross read-only,
765    /// never the root (2026-09-01 adversarial audit, H12). The pre-audit
766    /// session posture mounted `$CARGO_HOME` whole with a matching `-e`,
767    /// which carried `credentials.toml` and the legacy extensionless
768    /// `credentials` — crates.io registry auth — into the container, while
769    /// the tier-2 process sandbox explicitly read-DENIES exactly those two
770    /// filenames. Tier 3, the tier `resolve_validator_containment_target`
771    /// calls "already the stronger containment", was therefore strictly
772    /// weaker than tier 2 for registry credentials. Session mode now gets
773    /// the [`ToolchainMount::Gate`] treatment plus the shared caches:
774    /// `<cargo>/bin`, `<cargo>/registry`, `<cargo>/git`.
775    Session,
776    /// Engine-run gates: the real Cargo root NEVER crosses — the gate's
777    /// `CARGO_HOME` is a seeded cache-only home precisely because the real
778    /// root carries registry credentials and credential-provider config
779    /// (`agent_env::cache_only_cargo_home`), and ro-mounting it would reopen
780    /// the exact read exposure that home exists to close. Only the shim dir
781    /// (`<cargo>/bin` — rustup proxies and installed binaries, never
782    /// credentials, which live at the root) is mounted so the forwarded
783    /// PATH's `cargo` shim resolves; the gate env's own `CARGO_HOME` (under
784    /// the rw scratch) crosses via the forwarded `-e` set instead.
785    Gate,
786}
787
788/// Toolchain caches cross as READ-ONLY mounts + matching env (6th-pass
789/// review: without them a container session cold-bootstraps a whole
790/// rustup toolchain + registry into scratch, the container twin of the
791/// m-533143 ENOSPC regression). rw would let a poisoned cache ride into
792/// the operator's later builds — the same class as a shared target/, so
793/// ro it is: a cache MISS (uncached crate) fails visibly inside the
794/// container rather than writing through to the operator's cache.
795fn push_toolchain_caches(out: &mut Vec<String>, mode: ToolchainMount) {
796    let global = crate::sandbox::global_authority_dir();
797    for (var, default_subdir) in [
798        ("RUSTUP_HOME", ".rustup"),
799        ("CARGO_HOME", ".cargo"),
800        ("NPM_CONFIG_CACHE", ".npm"),
801    ] {
802        let host = std::env::var_os(var)
803            .map(std::path::PathBuf::from)
804            .or_else(|| {
805                std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(default_subdir))
806            });
807        if let Some(host) = host {
808            if global
809                .as_ref()
810                .is_some_and(|dir| crate::sandbox::absolutize(&host).starts_with(dir))
811            {
812                continue;
813            }
814            if var == "CARGO_HOME" {
815                // The credential-bearing ROOT never crosses in either mode
816                // (H12): `credentials.toml` and the legacy extensionless
817                // `credentials` live there, and the process tier read-denies
818                // both. Only leaf dirs are mounted, so the container's
819                // CARGO_HOME contains exactly what was mounted into it and
820                // nothing else.
821                //
822                // Gate mode takes the shim dir alone and forwards the gate
823                // env's own cache-only CARGO_HOME instead of emitting one.
824                // Session mode adds the shared registry/git caches (without
825                // them a container session cold-bootstraps the whole
826                // registry into scratch — the m-533143 ENOSPC shape) and
827                // names the same path in `-e`: with the root unmounted, that
828                // env value resolves to a CACHE-ONLY home inside the
829                // container, assembled from the ro leaf mounts.
830                let leaves: &[&str] = match mode {
831                    ToolchainMount::Gate => &["bin"],
832                    ToolchainMount::Session => &["bin", "registry", "git"],
833                };
834                let mut mounted_any = false;
835                for leaf in leaves {
836                    let dir = host.join(leaf);
837                    if dir.is_dir() {
838                        let mounted = container_host_path(&dir);
839                        out.push("-v".to_string());
840                        out.push(mount_arg(&mounted, true));
841                        mounted_any = true;
842                    }
843                }
844                if mode == ToolchainMount::Session && mounted_any {
845                    out.push("-e".to_string());
846                    out.push(format!("CARGO_HOME={}", container_host_path(&host)));
847                }
848                continue;
849            }
850            if host.is_dir() {
851                let mounted = container_host_path(&host);
852                out.push("-v".to_string());
853                out.push(mount_arg(&mounted, true));
854                out.push("-e".to_string());
855                out.push(format!("{var}={mounted}"));
856            }
857        }
858    }
859}
860
861/// The network posture: `fs+net` with an empty egress list maps to
862/// `--network none` (the hard boundary); `fs+net` with a non-empty egress
863/// list forwards the relay endpoint after `container_run_args` has attached
864/// the unique internal network. Engine-run gates are never wired through the
865/// relay and their resolution FAILS CLOSED on that pair. `fs` passes no
866/// network flag.
867fn push_network(out: &mut Vec<String>, inputs: &SandboxInputs, proxy_url: Option<&str>) {
868    if inputs.enforce == crate::types::SandboxEnforce::FsNet {
869        if inputs.egress.is_empty() {
870            out.push("--network".to_string());
871            out.push("none".to_string());
872        } else if let Some(proxy_url) = proxy_url {
873            out.push("-e".to_string());
874            out.push(format!(
875                "{}={proxy_url}",
876                crate::egress_proxy::HTTPS_PROXY_ENV
877            ));
878            out.push("-e".to_string());
879            out.push(format!(
880                "{}={proxy_url}",
881                crate::egress_proxy::HTTP_PROXY_ENV
882            ));
883            out.push("-e".to_string());
884            out.push(format!(
885                "{}={}",
886                crate::egress_proxy::NO_PROXY_ENV,
887                crate::egress_proxy::NO_PROXY_VALUE
888            ));
889        }
890    }
891}
892
893pub fn container_run_args(
894    inputs: &SandboxInputs,
895    spec: &ContainerSpec,
896    binary: &Path,
897    args: &[String],
898    proxy_url: Option<&str>,
899) -> Vec<String> {
900    let mut out = run_prologue(inputs);
901    if let Some(name) = &spec.name {
902        out.push("--name".to_string());
903        out.push(name.clone());
904    }
905    push_policy_mounts(&mut out, inputs);
906    push_workdir_and_scratch_env(&mut out, inputs);
907    push_toolchain_caches(&mut out, ToolchainMount::Session);
908    push_authority_masks(&mut out, inputs);
909    if inputs.enforce == crate::types::SandboxEnforce::FsNet && !inputs.egress.is_empty() {
910        if let (Some(network), Some(_)) = (&spec.network, proxy_url) {
911            out.push("--network".to_string());
912            out.push(network.clone());
913            push_network(&mut out, inputs, proxy_url);
914        } else {
915            // Defense in depth: a non-empty allowlist without a fully
916            // provisioned boundary gets no network, never the default bridge.
917            out.push("--network".to_string());
918            out.push("none".to_string());
919        }
920    } else {
921        push_network(&mut out, inputs, proxy_url);
922    }
923    out.push(spec.image.clone());
924    out.push(binary.display().to_string());
925    out.extend(args.iter().cloned());
926    out
927}
928
929/// Env vars the gate builder itself emits (the scratch block's HOME/TMPDIR,
930/// the cache block's RUSTUP_HOME/NPM_CONFIG_CACHE) or deliberately ignores
931/// (the Windows TEMP pair — POSIX scratch TMPDIR is the in-container temp
932/// posture): the forwarded caller env must not duplicate them. `CARGO_HOME`
933/// is NOT skipped — gate mode suppresses the cache block's own CARGO_HOME
934/// (the credential root never crosses), so the caller's cache-only home
935/// under the rw scratch is the one the gate sees.
936const GATE_FORWARD_ENV_SKIP: &[&str] = &[
937    "HOME",
938    "TMPDIR",
939    "TMP",
940    "TEMP",
941    "RUSTUP_HOME",
942    "NPM_CONFIG_CACHE",
943];
944
945/// Build the `<runtime> run` argv for ONE engine-run gate command (ticket
946/// container-gate-wrapper): the same read-only-root + declared-mount shape
947/// an agent session gets, with four gate-specific deltas.
948///
949/// - The payload is `sh -c <command>` (contract/gate commands are
950///   user-authored shell lines needing real shell semantics — the same
951///   trust decision the host gate makes in `command_exec::shell_argv`), not
952///   an agent binary.
953/// - `--name <container_name>`: the bounded core's timeout SIGKILL reaches
954///   the runtime CLIENT's process group, not the in-container tree (the
955///   daemon owns those processes), so the caller force-removes the named
956///   container on the timeout path. `--rm` still reaps every normal exit.
957/// - The gate's COMPLETE sanitized env crosses via `-e` flags — `docker run`
958///   forwards no client env into the container, and contract commands need
959///   `KRANZ_BASE_SHA`, the cache-only `CARGO_HOME`, and PATH. The env the
960///   caller hands over is already the allowlisted contract/merge env
961///   (`agent_env::contract_command_env`, or `command_exec::sanitized_gate_env`
962///   with its cache-only CARGO_HOME), never ambient secrets; the keys the
963///   builder emits itself ([`GATE_FORWARD_ENV_SKIP`]) are excluded, and the
964///   order is sorted so the argv is deterministic.
965/// - Toolchain posture is [`ToolchainMount::Gate`]: the rustup toolchain and
966///   npm cache cross read-only (the gate runs the repo's own toolchain from
967///   the host's rustup — the established ro-mount pattern), the real Cargo
968///   root NEVER crosses (credential directory — only `<cargo>/bin`'s shims
969///   do). Image assumption: the configured `sandbox.image` must carry
970///   whatever the host toolchain mounts do not (a non-rustup cargo, node,
971///   go…) — the same assumption worker sessions already carry, documented in
972///   the module doc; with `DEFAULT_IMAGE` a `cargo` gate fails loudly with
973///   "not found", never silently on the host.
974///
975/// `fs+net` keeps the session handling (empty egress → `--network none`);
976/// `fs+net` with a NON-EMPTY egress list must have been refused by the
977/// resolution (fail closed — no proxy exists for engine-side gates), so
978/// `push_network` is called with `proxy_url: None` here.
979pub fn container_gate_run_args(
980    inputs: &SandboxInputs,
981    spec: &ContainerSpec,
982    command: &str,
983    env: &std::collections::HashMap<String, String>,
984    container_name: &str,
985) -> Vec<String> {
986    let mut out = run_prologue(inputs);
987    out.push("--name".to_string());
988    out.push(container_name.to_string());
989    push_policy_mounts(&mut out, inputs);
990    push_workdir_and_scratch_env(&mut out, inputs);
991    push_toolchain_caches(&mut out, ToolchainMount::Gate);
992    push_authority_masks(&mut out, inputs);
993    push_network(&mut out, inputs, None);
994    let mut forwarded: Vec<(&String, &String)> = env.iter().collect();
995    forwarded.sort_by_key(|(key, _)| *key);
996    for (key, value) in forwarded {
997        if GATE_FORWARD_ENV_SKIP.contains(&key.as_str()) {
998            continue;
999        }
1000        out.push("-e".to_string());
1001        out.push(format!("{key}={value}"));
1002    }
1003    out.push(spec.image.clone());
1004    out.push("sh".to_string());
1005    out.push("-c".to_string());
1006    out.push(command.to_string());
1007    out
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use crate::sandbox::SandboxInputs;
1014    use crate::types::SandboxEnforce;
1015    use std::path::PathBuf;
1016
1017    #[test]
1018    fn declared_roots_follow_the_scratch_override_not_the_temp_dir() {
1019        let case =
1020            "sandbox_container::tests::declared_roots_follow_the_scratch_override_not_the_temp_dir";
1021        if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() != Ok(case) {
1022            let shared = tempfile::tempdir().unwrap();
1023            let output = std::process::Command::new(std::env::current_exe().unwrap())
1024                .args([case, "--exact", "--nocapture"])
1025                .env("KRANZ_SCRATCH_TEST_CASE", case)
1026                .env(crate::backend_claude::SCRATCH_ROOT_ENV, shared.path())
1027                .output()
1028                .unwrap();
1029            assert!(
1030                output.status.success(),
1031                "{}",
1032                String::from_utf8_lossy(&output.stderr)
1033            );
1034            assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
1035            return;
1036        }
1037        let checkout = std::path::Path::new("/repos/app/worktree");
1038        let mission = std::path::Path::new("/repos/app/.kranz/missions/m-1");
1039        let shared =
1040            PathBuf::from(std::env::var_os(crate::backend_claude::SCRATCH_ROOT_ENV).unwrap());
1041        let roots = declared_mount_roots(checkout, mission, &[]);
1042
1043        // Proving the temp dir an operator no longer uses would refuse a
1044        // mission that works, and proving nothing where scratch really lives
1045        // would lose its output silently. The proof follows the session.
1046        assert!(roots.contains(&shared), "{roots:?}");
1047        assert!(!roots.contains(&std::env::temp_dir()), "{roots:?}");
1048        assert!(
1049            roots.contains(&std::path::PathBuf::from("/repos/app")),
1050            "the checkout's parent is mounted, not the worktree itself: {roots:?}"
1051        );
1052    }
1053
1054    #[test]
1055    fn mount_proof_argv_reads_the_host_sentinel_and_writes_the_guest_one() {
1056        // The host side is spelled by the platform, not by this test: on
1057        // Windows `absolutize` returns a drive path, and the verbatim form is
1058        // what once broke docker's colon-delimited parser. Assert the
1059        // COMPOSITION — host path, then the guest mount point — rather than a
1060        // POSIX literal that only holds on unix.
1061        let host = std::env::temp_dir();
1062        let argv = mount_proof_argv(&host, "alpine:3", "guestsentinel");
1063        let rendered = argv.join(" ");
1064        let expected_mount = format!("{}:/kranz-mount-proof", container_host_path(&host));
1065        assert!(rendered.contains(&expected_mount), "{rendered}");
1066        assert!(!expected_mount.starts_with(r"\\?\"), "{expected_mount}");
1067        // Both directions in one run: a mount can be visible one way and
1068        // stale the other.
1069        assert!(
1070            rendered.contains("cat /kranz-mount-proof/host.txt"),
1071            "{rendered}"
1072        );
1073        assert!(
1074            rendered.contains("printf %s guestsentinel > /kranz-mount-proof/guest.txt"),
1075            "{rendered}"
1076        );
1077        // A missing sentinel must not become a shell error, or an unshared
1078        // mount is indistinguishable from a dead daemon.
1079        assert!(rendered.contains("no-host-sentinel"), "{rendered}");
1080        assert!(rendered.starts_with("run --rm "), "{rendered}");
1081    }
1082
1083    #[test]
1084    fn live_bind_mount_round_trip_closes_under_the_checkout() {
1085        // Windows refuses the provider whatever a probe says, so a probe
1086        // there proves nothing and would fail on the Linux image alone.
1087        if cfg!(target_os = "windows") {
1088            crate::test_capability::skip(
1089                crate::test_capability::capability::CONTAINER,
1090                "the container provider refuses Windows, so a bind-mount probe proves nothing",
1091            );
1092            return;
1093        }
1094        let Some(runtime) = detect() else {
1095            crate::test_capability::skip(
1096                crate::test_capability::capability::CONTAINER,
1097                "no container runtime on PATH, so the bind-mount round trip cannot be proven",
1098            );
1099            return;
1100        };
1101        // The checkout's parent, not a temp dir: a runtime can share one and
1102        // not the other, and this is the path a mission actually mounts.
1103        let checkout = std::env::current_dir().expect("a working directory");
1104        let root = checkout.parent().unwrap_or(&checkout);
1105        match prove_bind_mount(runtime, root, DEFAULT_IMAGE) {
1106            MountProof::Proven => {}
1107            MountProof::Failed(reason) => panic!(
1108                "the bind-mount round trip under {} did not close, so a mission's \
1109                 declared write set cannot be trusted here: {reason}",
1110                root.display()
1111            ),
1112        }
1113    }
1114
1115    #[test]
1116    fn detect_prefers_docker_then_podman_then_nerdctl_then_apple_container() {
1117        assert_eq!(detect_with(|_| false), None);
1118        assert_eq!(
1119            detect_with(|name| name == "container"),
1120            Some(ContainerRuntime::AppleContainer)
1121        );
1122        assert_eq!(
1123            detect_with(|name| name == "nerdctl" || name == "container"),
1124            Some(ContainerRuntime::Nerdctl)
1125        );
1126        assert_eq!(
1127            detect_with(|name| name == "podman" || name == "nerdctl"),
1128            Some(ContainerRuntime::Podman)
1129        );
1130        assert_eq!(
1131            detect_with(|name| name == "docker" || name == "podman"),
1132            Some(ContainerRuntime::Docker)
1133        );
1134    }
1135
1136    fn inputs(enforce: SandboxEnforce) -> SandboxInputs {
1137        SandboxInputs {
1138            enforce,
1139            session_cwd: PathBuf::from("/work/session"),
1140            mission_dir: PathBuf::from("/work/mission"),
1141            tmpdir: PathBuf::from("/work/scratch"),
1142            extra_write: vec![PathBuf::from("/home/op/.cargo")],
1143            egress: Vec::new(),
1144            validator_read_deny_roots: Vec::new(),
1145        }
1146    }
1147
1148    fn spec() -> ContainerSpec {
1149        ContainerSpec {
1150            runtime: ContainerRuntime::Docker,
1151            image: DEFAULT_IMAGE.to_string(),
1152            network: None,
1153            name: None,
1154        }
1155    }
1156
1157    fn live_fixture() -> tempfile::TempDir {
1158        // Desktop VMs share the checkout but often not macOS /var/folders.
1159        // A host-only temp path can otherwise create a different empty VM
1160        // directory and make a mount test pass/fail for the wrong reason.
1161        tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap()
1162    }
1163
1164    #[test]
1165    fn container_run_args_fs_net_with_empty_egress_disables_network() {
1166        let args = container_run_args(
1167            &inputs(SandboxEnforce::FsNet),
1168            &spec(),
1169            Path::new("claude"),
1170            &["-p".to_string(), "hi".to_string()],
1171            None,
1172        );
1173        let network = args
1174            .windows(2)
1175            .find(|w| w[0] == "--network")
1176            .expect("fs+net must pass a --network flag");
1177        assert_eq!(network[1], "none");
1178    }
1179
1180    #[test]
1181    fn container_run_args_fs_net_with_egress_uses_internal_network_and_relay_env() {
1182        let mut inputs = inputs(SandboxEnforce::FsNet);
1183        inputs.egress = vec!["crates.io:443".to_string()];
1184        let mut spec = spec();
1185        spec.network = Some("kranz-egress-test".to_string());
1186        spec.name = Some("kranz-egress-worker-test".to_string());
1187        let args = container_run_args(
1188            &inputs,
1189            &spec,
1190            Path::new("claude"),
1191            &["-p".to_string(), "hi".to_string()],
1192            Some("http://kranz-egress:3128"),
1193        );
1194
1195        assert!(
1196            args.windows(2)
1197                .any(|w| w[0] == "--network" && w[1] == "kranz-egress-test"),
1198            "proxy-routed fs+net must use the per-run internal network: {args:?}"
1199        );
1200        assert!(
1201            args.windows(2)
1202                .any(|w| w[0] == "--name" && w[1] == "kranz-egress-worker-test"),
1203            "the daemon-owned worker must be named for timeout teardown: {args:?}"
1204        );
1205        for var in ["HTTPS_PROXY", "HTTP_PROXY"] {
1206            assert!(
1207                args.windows(2)
1208                    .any(|w| w[0] == "-e" && w[1] == format!("{var}=http://kranz-egress:3128")),
1209                "missing -e {var}=…: {args:?}"
1210            );
1211        }
1212        assert!(
1213            args.windows(2)
1214                .any(|w| w[0] == "-e" && w[1] == "NO_PROXY=localhost,127.0.0.1"),
1215            "missing -e NO_PROXY…: {args:?}"
1216        );
1217    }
1218
1219    #[test]
1220    fn container_run_args_fs_net_with_egress_fails_closed_without_boundary() {
1221        let mut inputs = inputs(SandboxEnforce::FsNet);
1222        inputs.egress = vec!["crates.io:443".to_string()];
1223        let args = container_run_args(
1224            &inputs,
1225            &spec(),
1226            Path::new("claude"),
1227            &[],
1228            Some("http://kranz-egress:3128"),
1229        );
1230        assert!(
1231            args.windows(2)
1232                .any(|w| w[0] == "--network" && w[1] == "none"),
1233            "missing boundary state must disable networking: {args:?}"
1234        );
1235        assert!(
1236            args.iter()
1237                .filter(|a| a.starts_with("HTTPS_PROXY="))
1238                .all(|a| a == "HTTPS_PROXY="),
1239            "a relay env must not be emitted without its internal network: {args:?}"
1240        );
1241    }
1242
1243    #[test]
1244    fn container_run_args_fs_keeps_runtime_default_network() {
1245        let args = container_run_args(
1246            &inputs(SandboxEnforce::Fs),
1247            &spec(),
1248            Path::new("claude"),
1249            &[],
1250            None,
1251        );
1252        assert!(
1253            !args.iter().any(|a| a == "--network"),
1254            "fs must not restrict the network (runtime default bridge): {args:?}"
1255        );
1256    }
1257
1258    #[test]
1259    fn container_run_args_mounts_policy_and_runs_image() {
1260        // Platform-native fixture paths: /work literals absolutize to
1261        // drive-lettered/backslashed forms on Windows, so expectations are
1262        // derived through the same absolutize + mount_arg the builder uses.
1263        let dir = tempfile::tempdir().unwrap();
1264        let session = dir.path().join("session");
1265        let mission = dir.path().join("mission");
1266        let scratch = dir.path().join("scratch");
1267        let cargo = dir.path().join("cargo");
1268        for path in [&session, &mission, &scratch, &cargo] {
1269            std::fs::create_dir_all(path).unwrap();
1270        }
1271        let inputs = SandboxInputs {
1272            enforce: SandboxEnforce::Fs,
1273            session_cwd: session.clone(),
1274            mission_dir: mission.clone(),
1275            tmpdir: scratch.clone(),
1276            extra_write: vec![cargo.clone()],
1277            egress: Vec::new(),
1278            validator_read_deny_roots: Vec::new(),
1279        };
1280        let args = container_run_args(
1281            &inputs,
1282            &spec(),
1283            Path::new("claude"),
1284            &["--print".to_string()],
1285            None,
1286        );
1287        let joined = args.join(" ");
1288        let abs = |p: &std::path::Path| container_host_path(p);
1289
1290        assert!(args.contains(&"--rm".to_string()));
1291        assert!(args.contains(&"--read-only".to_string()));
1292        assert!(joined.contains(&mount_arg(&abs(&session), false)));
1293        assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1294        assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1295        assert!(joined.contains(&mount_arg(&abs(&cargo), false)));
1296        assert!(joined.contains(&format!("-w {}", abs(&session))));
1297        assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1298        assert!(
1299            joined.ends_with(&format!("{DEFAULT_IMAGE} claude --print")),
1300            "image then binary then args: {args:?}"
1301        );
1302    }
1303
1304    #[test]
1305    fn container_run_args_mask_authority_material_under_session_root() {
1306        let dir = tempfile::tempdir().unwrap();
1307        let session = dir.path().join("session");
1308        let kranz_dir = session.join(".kranz");
1309        std::fs::create_dir_all(&kranz_dir).unwrap();
1310        let masked_token_file = kranz_dir.join("serve.token");
1311        let config = kranz_dir.join("config.json");
1312        std::fs::write(&masked_token_file, "secret").unwrap();
1313        std::fs::write(&config, "{}").unwrap();
1314        let mut inputs = inputs(SandboxEnforce::Fs);
1315        inputs.session_cwd = session;
1316
1317        let args = container_run_args(
1318            &inputs,
1319            &spec(),
1320            Path::new("claude"),
1321            &["--print".to_string()],
1322            None,
1323        );
1324        let joined = args.join(" ");
1325        let abs = |p: &std::path::Path| container_host_path(p);
1326
1327        assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir))));
1328        for name in ["serve.token", "serve.read.token", "config.json"] {
1329            assert!(
1330                !joined.contains(&abs(&kranz_dir.join(name))),
1331                "authority must stay outside the private view: {args:?}"
1332            );
1333        }
1334    }
1335
1336    /// MED-3 (2026-09-01 adversarial audit): the mask set was a hand-copied
1337    /// three-name list that had already drifted from the process tier,
1338    /// missing `domain-terms.local`, the `hook-status/` projection, and the
1339    /// mission `control/` inbox — which the `:ro` mission mount made
1340    /// READABLE inside the container, the exact posture
1341    /// `authority_read_deny_dirs` exists to close. Driving the masks off the
1342    /// process tier's own sets is what stops the two drifting again.
1343    #[test]
1344    fn container_run_args_mask_the_whole_process_tier_authority_set() {
1345        let dir = tempfile::tempdir().unwrap();
1346        let session = dir.path().join("session");
1347        let kranz = session.join(".kranz");
1348        let mission = kranz.join("missions").join("m-x");
1349        std::fs::create_dir_all(mission.join("control")).unwrap();
1350        std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1351        std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1352        std::fs::create_dir_all(kranz.join("queue")).unwrap();
1353        for name in ["serve.token", "config.json", "domain-terms.local"] {
1354            std::fs::write(kranz.join(name), "secret").unwrap();
1355        }
1356        let mut inputs = inputs(SandboxEnforce::Fs);
1357        inputs.session_cwd = session;
1358        inputs.mission_dir = mission.clone();
1359
1360        let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1361        let joined = args.join(" ");
1362        let abs = |p: &std::path::Path| container_host_path(p);
1363
1364        for name in [
1365            "serve.token",
1366            "config.json",
1367            "domain-terms.local",
1368            "hook-status",
1369        ] {
1370            assert!(
1371                !joined.contains(&abs(&kranz.join(name))),
1372                "authority must not be rebound: {args:?}"
1373            );
1374        }
1375        assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz))));
1376        assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1377        assert!(!joined.contains(&abs(&mission.join("control"))));
1378        // The WRITE-deny half is readable-but-unwritable, never shadowed
1379        // (follow-up review, H-1): the engine-owned stores and the sibling
1380        // mission dir stay legible while the rw session mount cannot carry a
1381        // write back to them.
1382        for readable in [kranz.join("queue"), kranz.join("missions")] {
1383            assert!(
1384                joined.contains(&mount_arg(&abs(&readable), true)),
1385                "missing :ro self-bind for {}: {args:?}",
1386                readable.display()
1387            );
1388        }
1389    }
1390
1391    /// H-1 (follow-up review): the WRITE-deny sets were folded into the two
1392    /// CONTENT-DESTROYING idioms — `/dev/null` file binds and empty `:ro`
1393    /// tmpfs shadows — so every TRACKED file under `.kranz/tickets/` and
1394    /// `.kranz/lessons/` read as deleted inside a checkout-mode container.
1395    /// The worker's own "commit your work" step then recorded the deletion of
1396    /// the whole ticket backlog onto the mission branch. The other two tiers
1397    /// implement the same deny as READABLE-but-unwritable (bwrap self
1398    /// ro-bind, a Windows ACE that keeps `FILE_GENERIC_READ`); this tier now
1399    /// does too, with the masks reserved for the READ-deny sets.
1400    #[test]
1401    fn container_run_args_keep_write_denied_kranz_content_readable() {
1402        let dir = tempfile::tempdir().unwrap();
1403        // Checkout mode: session_cwd IS the repo root, the hostile shape —
1404        // and the tracked ticket/lesson stores ride in on the rw session
1405        // mount.
1406        let session = dir.path().join("repo");
1407        let kranz = session.join(".kranz");
1408        let mission = kranz.join("missions").join("m-x");
1409        std::fs::create_dir_all(mission.join("control")).unwrap();
1410        std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1411        std::fs::create_dir_all(kranz.join("tickets")).unwrap();
1412        std::fs::create_dir_all(kranz.join("lessons")).unwrap();
1413        std::fs::create_dir_all(kranz.join("queue")).unwrap();
1414        std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1415        std::fs::write(kranz.join("tickets").join("some-ticket.md"), "# tracked").unwrap();
1416        std::fs::write(kranz.join("merge-gates.json"), "{}").unwrap();
1417        std::fs::write(kranz.join("secret-allowlist"), "OK_TOKEN\n").unwrap();
1418        for name in ["serve.token", "config.json"] {
1419            std::fs::write(kranz.join(name), "secret").unwrap();
1420        }
1421        let mut inputs = inputs(SandboxEnforce::Fs);
1422        inputs.session_cwd = session;
1423        inputs.mission_dir = mission.clone();
1424
1425        let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1426        let joined = args.join(" ");
1427        let abs = |p: &std::path::Path| container_host_path(p);
1428
1429        // Write-denied CONTENT: readable, unwritable — never masked.
1430        for readable in [
1431            kranz.join("tickets"),
1432            kranz.join("lessons"),
1433            kranz.join("queue"),
1434            kranz.join("missions"),
1435        ] {
1436            assert!(
1437                joined.contains(&mount_arg(&abs(&readable), true)),
1438                "{} must be a :ro self-bind, not a mask: {args:?}",
1439                readable.display()
1440            );
1441            assert!(
1442                !joined.contains(&format!("--tmpfs {}:ro", abs(&readable))),
1443                "{} must not be shadowed by an empty tmpfs: {args:?}",
1444                readable.display()
1445            );
1446        }
1447        for readable in [
1448            kranz.join("merge-gates.json"),
1449            kranz.join("secret-allowlist"),
1450        ] {
1451            assert!(
1452                joined.contains(&mount_arg(&abs(&readable), true)),
1453                "{} must be a :ro self-bind: {args:?}",
1454                readable.display()
1455            );
1456            assert!(
1457                !joined.contains(&format!("/dev/null:{}:ro", abs(&readable))),
1458                "{} must not read as zero bytes: {args:?}",
1459                readable.display()
1460            );
1461        }
1462
1463        // Read-denied entries never cross the private directory views.
1464        for hidden in [
1465            kranz.join("serve.token"),
1466            kranz.join("config.json"),
1467            mission.join("control"),
1468            kranz.join("hook-status"),
1469        ] {
1470            assert!(
1471                !joined.contains(&abs(&hidden)),
1472                "read-denied entry was mounted: {args:?}"
1473            );
1474        }
1475    }
1476
1477    /// MED-2 (2026-09-01 adversarial audit): the worker and gate containers
1478    /// got none of the hardening the egress relay already gets, so the agent
1479    /// ran as uid 0 with Docker's default capability set while its rw binds
1480    /// landed at the IDENTICAL host path.
1481    #[test]
1482    fn container_run_args_harden_the_worker_like_the_egress_relay() {
1483        // A REAL session dir: `--user` is derived by stat'ing the rw mount,
1484        // so a fixture path that does not exist would silently drop the flag.
1485        let session = tempfile::tempdir().unwrap();
1486        let mut inputs = inputs(SandboxEnforce::Fs);
1487        inputs.session_cwd = session.path().to_path_buf();
1488        let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1489
1490        assert!(args
1491            .windows(2)
1492            .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
1493        assert!(args
1494            .windows(2)
1495            .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
1496        assert!(args
1497            .windows(2)
1498            .any(|w| w[0] == "--pids-limit" && w[1] == CONTAINER_PIDS_LIMIT));
1499        #[cfg(unix)]
1500        {
1501            // The owner of the rw session mount, so container writes land as
1502            // the operator rather than as root in the operator's own tree.
1503            let expected = crate::container_egress::mount_owner(session.path())
1504                .expect("a stat-able path yields an owner");
1505            assert!(
1506                args.windows(2)
1507                    .any(|w| w[0] == "--user" && w[1] == expected),
1508                "missing --user {expected}: {args:?}"
1509            );
1510        }
1511    }
1512
1513    /// H12 (2026-09-01 adversarial audit): session mode mounted the whole
1514    /// `$CARGO_HOME` read-only with a matching `-e`, carrying
1515    /// `credentials.toml` (crates.io registry auth) into the container —
1516    /// while the tier-2 process sandbox explicitly read-DENIES exactly that
1517    /// file. Tier 3 was therefore weaker than tier 2 for registry
1518    /// credentials. Only the cache leaves cross now.
1519    #[test]
1520    fn container_run_args_never_mount_the_real_cargo_root_for_a_session() {
1521        let home = tempfile::tempdir().unwrap();
1522        let cargo = home.path().join(".cargo");
1523        for leaf in ["bin", "registry", "git"] {
1524            std::fs::create_dir_all(cargo.join(leaf)).unwrap();
1525        }
1526        std::fs::write(cargo.join("credentials.toml"), "[registry]\ntoken=\"x\"\n").unwrap();
1527        let _guard = crate::agent_env::EnvTestGuard::engage(&[
1528            ("CARGO_HOME", cargo.to_str().unwrap()),
1529            ("HOME", home.path().to_str().unwrap()),
1530        ]);
1531
1532        let mut out = Vec::new();
1533        push_toolchain_caches(&mut out, ToolchainMount::Session);
1534        let joined = out.join(" ");
1535        let root = container_host_path(&cargo);
1536
1537        assert!(
1538            !joined.contains(&mount_arg(&root, true)),
1539            "the credential-bearing Cargo root must never be mounted: {out:?}"
1540        );
1541        for leaf in ["bin", "registry", "git"] {
1542            let mounted = container_host_path(&cargo.join(leaf));
1543            assert!(
1544                joined.contains(&mount_arg(&mounted, true)),
1545                "the {leaf} cache leaf must still cross read-only: {out:?}"
1546            );
1547        }
1548        // The env still names a CARGO_HOME, but with the root unmounted it
1549        // resolves to a cache-only home assembled from the leaf mounts.
1550        assert!(
1551            out.windows(2)
1552                .any(|w| w[0] == "-e" && w[1] == format!("CARGO_HOME={root}")),
1553            "session mode must forward the cache-only CARGO_HOME: {out:?}"
1554        );
1555    }
1556
1557    /// The gate argv shape (ticket container-gate-wrapper): the same
1558    /// declared-mount policy an agent session gets (gate cwd rw, mission dir
1559    /// ro, scratch rw, extra_write rw, authority masks, `-w`, scratch
1560    /// HOME/TMPDIR), PLUS the gate deltas — a named container, the caller's
1561    /// sanitized env forwarded as sorted `-e` flags (minus the keys the
1562    /// builder emits itself), and an `sh -c <command>` payload after the
1563    /// image. Expectations derive paths through the same absolutize +
1564    /// mount_arg the builder uses (POSIX/Windows path forms differ).
1565    #[test]
1566    fn container_gate_wrap_args_mounts_policy_forwards_env_and_payload() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let gate = dir.path().join("gate");
1569        let mission = dir.path().join("mission");
1570        let scratch = dir.path().join("scratch");
1571        let extra = dir.path().join("extra");
1572        for dir in [&gate, &mission, &scratch, &extra] {
1573            std::fs::create_dir_all(dir).unwrap();
1574        }
1575        let kranz_dir = gate.join(".kranz");
1576        std::fs::create_dir_all(&kranz_dir).unwrap();
1577        let masked_token_file = kranz_dir.join("serve.token");
1578        std::fs::write(&masked_token_file, "secret").unwrap();
1579        let inputs = SandboxInputs {
1580            enforce: SandboxEnforce::Fs,
1581            session_cwd: gate.clone(),
1582            mission_dir: mission.clone(),
1583            tmpdir: scratch.clone(),
1584            extra_write: vec![extra.clone()],
1585            egress: Vec::new(),
1586            validator_read_deny_roots: Vec::new(),
1587        };
1588        let env: std::collections::HashMap<String, String> = [
1589            ("ZZZ_BASE".to_string(), "deadbeef".to_string()),
1590            ("AAA_FIRST".to_string(), "1".to_string()),
1591            ("CARGO_HOME".to_string(), "/scratch/cache-only".to_string()),
1592            ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1593            // The builder-owned keys: forwarded copies of these must NOT
1594            // appear with the caller's values.
1595            ("HOME".to_string(), "/caller/home".to_string()),
1596            ("TMPDIR".to_string(), "/caller/tmp".to_string()),
1597            ("RUSTUP_HOME".to_string(), "/caller/rustup".to_string()),
1598            ("NPM_CONFIG_CACHE".to_string(), "/caller/npm".to_string()),
1599        ]
1600        .into_iter()
1601        .collect();
1602
1603        let args = container_gate_run_args(
1604            &inputs,
1605            &spec(),
1606            "cargo test --workspace",
1607            &env,
1608            "kranz-gate-test",
1609        );
1610        let joined = args.join(" ");
1611        let abs = |p: &std::path::Path| container_host_path(p);
1612
1613        // The session mount policy, unchanged.
1614        assert!(args.contains(&"--read-only".to_string()));
1615        assert!(joined.contains(&mount_arg(&abs(&gate), false)));
1616        assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1617        assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1618        assert!(joined.contains(&mount_arg(&abs(&extra), false)));
1619        assert!(joined.contains(&format!("-w {}", abs(&gate))));
1620        assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1621        assert!(joined.contains(&format!("-e TMPDIR={}", abs(&scratch))));
1622        assert!(
1623            joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir)))
1624                && !joined.contains(&abs(&masked_token_file)),
1625            "authority material must stay outside the private directory: {args:?}"
1626        );
1627
1628        // The gate deltas: named container, sh -c payload after the image.
1629        assert!(
1630            args.windows(2)
1631                .any(|w| w[0] == "--name" && w[1] == "kranz-gate-test"),
1632            "the gate container must carry the caller-chosen name: {args:?}"
1633        );
1634        assert!(
1635            joined.ends_with(&format!("{DEFAULT_IMAGE} sh -c cargo test --workspace")),
1636            "image then sh -c payload: {args:?}"
1637        );
1638
1639        // The caller env crosses — sorted (AAA before ZZZ)…
1640        let index_of = |needle: &str| {
1641            args.windows(2)
1642                .position(|w| w[0] == "-e" && w[1] == needle)
1643                .unwrap_or_else(|| panic!("missing -e {needle}: {args:?}"))
1644        };
1645        assert!(index_of("AAA_FIRST=1") < index_of("ZZZ_BASE=deadbeef"));
1646        index_of("CARGO_HOME=/scratch/cache-only");
1647        index_of("PATH=/usr/bin:/bin");
1648        // …minus the keys the builder emits itself (no caller-valued
1649        // duplicates of HOME/TMPDIR/the toolchain cache vars).
1650        for skipped in [
1651            "-e HOME=/caller/home",
1652            "-e TMPDIR=/caller/tmp",
1653            "-e RUSTUP_HOME=/caller/rustup",
1654            "-e NPM_CONFIG_CACHE=/caller/npm",
1655        ] {
1656            assert!(
1657                !joined.contains(skipped),
1658                "builder-owned env key must not be forwarded with the caller value: {skipped}\n{args:?}"
1659            );
1660        }
1661    }
1662
1663    /// The gate toolchain posture (ticket container-gate-wrapper): the real
1664    /// Cargo root NEVER crosses — it is a credential directory
1665    /// (`credentials.toml` rides at its root), and the gate's cache-only
1666    /// CARGO_HOME exists precisely to keep those bytes away from
1667    /// worker-authored gate code. Only the credential-free `<cargo>/bin`
1668    /// shim dir is mounted (ro), so the forwarded PATH's rustup shim
1669    /// resolves; the caller's cache-only CARGO_HOME crosses via `-e`.
1670    #[test]
1671    fn container_gate_wrap_args_never_mounts_the_real_cargo_root() {
1672        let cargo = tempfile::tempdir().unwrap();
1673        std::fs::create_dir_all(cargo.path().join("bin")).unwrap();
1674        std::fs::write(cargo.path().join("credentials.toml"), "operator-secret").unwrap();
1675        let _guard = crate::agent_env::EnvTestGuard::engage(&[(
1676            "CARGO_HOME",
1677            cargo.path().to_str().expect("utf-8 temp path"),
1678        )]);
1679
1680        let dir = tempfile::tempdir().unwrap();
1681        let inputs = SandboxInputs {
1682            enforce: SandboxEnforce::Fs,
1683            session_cwd: dir.path().join("gate"),
1684            mission_dir: dir.path().join("mission"),
1685            tmpdir: dir.path().join("scratch"),
1686            extra_write: Vec::new(),
1687            egress: Vec::new(),
1688            validator_read_deny_roots: Vec::new(),
1689        };
1690        let env: std::collections::HashMap<String, String> =
1691            [("CARGO_HOME".to_string(), "/scratch/cache-only".to_string())]
1692                .into_iter()
1693                .collect();
1694        let args = container_gate_run_args(&inputs, &spec(), "true", &env, "kranz-gate-test");
1695        let joined = args.join(" ");
1696        let abs = |p: &std::path::Path| container_host_path(p);
1697
1698        let root = abs(cargo.path());
1699        let bin = abs(&cargo.path().join("bin"));
1700        assert!(
1701            joined.contains(&mount_arg(&bin, true)),
1702            "the shim dir must cross read-only: {args:?}"
1703        );
1704        assert!(
1705            !joined.contains(&mount_arg(&root, true)),
1706            "the credential-bearing Cargo root must NEVER be mounted: {args:?}"
1707        );
1708        assert!(
1709            !joined.contains(&format!("-e CARGO_HOME={root}")),
1710            "no -e may point CARGO_HOME at the real root: {args:?}"
1711        );
1712        assert!(
1713            joined.contains("-e CARGO_HOME=/scratch/cache-only"),
1714            "the caller's cache-only CARGO_HOME crosses instead: {args:?}"
1715        );
1716    }
1717
1718    /// The gate network posture mirrors the session container's (ticket
1719    /// container-gate-wrapper): `fs+net` with an empty egress list is the
1720    /// hard `--network none` boundary (engine-run gates are never wired
1721    /// through the egress proxy, and the resolution FAILS CLOSED on a
1722    /// non-empty list, so the builder never sees the proxy-routed branch);
1723    /// `fs` keeps the runtime default bridge/NAT.
1724    #[test]
1725    fn container_gate_wrap_args_fs_net_empty_egress_disables_network() {
1726        let env = std::collections::HashMap::new();
1727        let fs_net = container_gate_run_args(
1728            &inputs(SandboxEnforce::FsNet),
1729            &spec(),
1730            "true",
1731            &env,
1732            "kranz-gate-test",
1733        );
1734        let network = fs_net
1735            .windows(2)
1736            .find(|w| w[0] == "--network")
1737            .expect("fs+net must pass a --network flag");
1738        assert_eq!(network[1], "none");
1739        assert!(
1740            fs_net
1741                .iter()
1742                .filter(|a| a.starts_with("HTTPS_PROXY="))
1743                .all(|a| a == "HTTPS_PROXY="),
1744            "offline gates must suppress inherited proxy configuration: {fs_net:?}"
1745        );
1746
1747        let fs = container_gate_run_args(
1748            &inputs(SandboxEnforce::Fs),
1749            &spec(),
1750            "true",
1751            &env,
1752            "kranz-gate-test",
1753        );
1754        assert!(
1755            !fs.iter().any(|a| a == "--network"),
1756            "fs must not restrict the network (runtime default bridge): {fs:?}"
1757        );
1758    }
1759
1760    #[test]
1761    fn container_run_args_respects_image_override() {
1762        let spec = ContainerSpec {
1763            runtime: ContainerRuntime::Podman,
1764            image: "ghcr.io/example/kranz-worker:1".to_string(),
1765            network: None,
1766            name: None,
1767        };
1768        let args = container_run_args(
1769            &inputs(SandboxEnforce::Fs),
1770            &spec,
1771            Path::new("claude"),
1772            &[],
1773            None,
1774        );
1775        assert!(
1776            args.iter().any(|a| a == "ghcr.io/example/kranz-worker:1"),
1777            "configured image must be used: {args:?}"
1778        );
1779        assert!(!args.iter().any(|a| a == DEFAULT_IMAGE));
1780    }
1781
1782    /// Smoke: a trivial worker inside the provider lands a write inside the
1783    /// mounted session dir on the host, a write outside the declared policy
1784    /// (`/etc`, read-only root fs) is denied, and authority material under the
1785    /// session root (`.kranz/serve.token`) is masked by its /dev/null bind.
1786    /// Skips outside the live-proven Linux host path or without a runtime;
1787    /// CI ubuntu-latest has Docker.
1788    #[test]
1789    fn container_provider_runs_a_trivial_worker_and_enforces_the_write_boundary() {
1790        if !host_supports_container_contract() {
1791            crate::test_capability::skip(
1792                crate::test_capability::capability::CONTAINER,
1793                &container_contract_skip_detail(),
1794            );
1795            return;
1796        }
1797        let Some(runtime) = detect() else {
1798            crate::test_capability::skip(
1799                crate::test_capability::capability::CONTAINER,
1800                "no docker/podman/nerdctl/container on PATH",
1801            );
1802            return;
1803        };
1804
1805        let session = live_fixture();
1806        let mission = live_fixture();
1807        let scratch = live_fixture();
1808        let kranz_dir = session.path().join(".kranz");
1809        std::fs::create_dir_all(&kranz_dir).unwrap();
1810        std::fs::write(kranz_dir.join("serve.token"), "secret").unwrap();
1811        let inputs = SandboxInputs {
1812            enforce: SandboxEnforce::FsNet,
1813            session_cwd: session.path().to_path_buf(),
1814            mission_dir: mission.path().to_path_buf(),
1815            tmpdir: scratch.path().to_path_buf(),
1816            extra_write: Vec::new(),
1817            egress: Vec::new(),
1818            validator_read_deny_roots: Vec::new(),
1819        };
1820        let spec = ContainerSpec {
1821            runtime,
1822            image: DEFAULT_IMAGE.to_string(),
1823            network: None,
1824            name: None,
1825        };
1826        let ok_file = session.path().join("ok.txt");
1827        let args = container_run_args(
1828            &inputs,
1829            &spec,
1830            Path::new("sh"),
1831            &[
1832                "-c".to_string(),
1833                format!(
1834                    "echo ok > {} && ! cat {} && echo nope > /etc/nope.txt",
1835                    ok_file.display(),
1836                    kranz_dir.join("serve.token").display()
1837                ),
1838            ],
1839            None,
1840        );
1841        let output = std::process::Command::new(runtime.binary())
1842            .args(&args)
1843            .stdin(std::process::Stdio::null())
1844            .output()
1845            .expect("failed to spawn container runtime");
1846
1847        assert!(
1848            ok_file.exists(),
1849            "write inside the mounted session_cwd must land on the host: {}",
1850            String::from_utf8_lossy(&output.stderr)
1851        );
1852        assert!(
1853            !output.status.success(),
1854            "write outside the declared policy (/etc) must be denied, failing the worker: {}",
1855            String::from_utf8_lossy(&output.stderr)
1856        );
1857        assert!(
1858            !String::from_utf8_lossy(&output.stdout).contains("secret"),
1859            "the /dev/null mask must hide serve.token content inside the container"
1860        );
1861    }
1862
1863    #[test]
1864    fn container_authority_directory_mask_covers_absent_and_future_tokens() {
1865        if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_mask_covers_absent_and_future_tokens") { return; }
1866        let home = tempfile::tempdir().unwrap();
1867        let _env = crate::agent_env::EnvTestGuard::engage(&[(
1868            if cfg!(windows) { "USERPROFILE" } else { "HOME" },
1869            home.path().to_str().unwrap(),
1870        )]);
1871        let global = home.path().join(".kranz");
1872        assert!(!global.exists());
1873        let mut inputs = inputs(SandboxEnforce::Fs);
1874        inputs.extra_write.extend([
1875            home.path().to_path_buf(),
1876            global.clone(),
1877            global.join("serve"),
1878        ]);
1879        for args in [
1880            container_run_args(&inputs, &spec(), Path::new("sh"), &[], None),
1881            container_gate_run_args(&inputs, &spec(), "true", &Default::default(), "test"),
1882        ] {
1883            assert!(
1884                args.windows(2).any(|pair| pair[0] == "--tmpfs"
1885                    && pair[1]
1886                        == format!(
1887                            "{}:ro,noexec,nosuid,nodev,mode=755",
1888                            container_host_path(&global)
1889                        )),
1890                "authority mask missing: {args:?}"
1891            );
1892            assert!(
1893                !args
1894                    .windows(2)
1895                    .any(|pair| pair[0] == "-v"
1896                        && pair[1].starts_with(&container_host_path(&global))),
1897                "nested mounts must not reopen global authority: {args:?}"
1898            );
1899        }
1900        // Building argv must never create placeholder credentials or mutate HOME.
1901        assert!(!global.exists());
1902    }
1903
1904    #[cfg(unix)]
1905    #[test]
1906    fn container_authority_directory_hides_tokens_created_after_start() {
1907        if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_hides_tokens_created_after_start") { return; }
1908        use std::io::{BufRead as _, Write as _};
1909        let Some(runtime) = detect() else {
1910            eprintln!("no container runtime; skipping live authority test");
1911            return;
1912        };
1913        let dir = live_fixture();
1914        let home = dir.path().join("operator");
1915        let session = dir.path().join("session");
1916        let mission = session.join(".kranz/missions/m-test");
1917        let scratch = dir.path().join("scratch");
1918        std::fs::create_dir_all(&home).unwrap();
1919        let authority_target = dir.path().join("private-authority");
1920        std::fs::create_dir(&authority_target).unwrap();
1921        std::os::unix::fs::symlink(&authority_target, home.join(".kranz")).unwrap();
1922        std::fs::create_dir_all(&mission).unwrap();
1923        std::fs::create_dir(&scratch).unwrap();
1924        let authority = home.join(".kranz/serve/later.token");
1925        let global_config = home.join(".kranz/config.json");
1926        let cargo = home.join(".cargo");
1927        std::fs::create_dir(&cargo).unwrap();
1928        let repo_token_path = session.join(".kranz/serve.token");
1929        let repo_read_token_path = session.join(".kranz/serve.read.token");
1930        let repo_config = session.join(".kranz/config.json");
1931        let cargo_credentials = cargo.join("credentials.toml");
1932        let policy = session.join(".kranz/merge-gates.json");
1933        std::fs::write(&repo_token_path, "original-token").unwrap();
1934        std::fs::write(&policy, "visible-policy").unwrap();
1935        let input = SandboxInputs {
1936            enforce: SandboxEnforce::FsNet,
1937            session_cwd: session.clone(),
1938            mission_dir: mission,
1939            tmpdir: scratch,
1940            extra_write: vec![home.clone(), home.join(".kranz/serve")],
1941            egress: Vec::new(),
1942            validator_read_deny_roots: Vec::new(),
1943        };
1944        let args = {
1945            let _env = crate::agent_env::EnvTestGuard::engage(&[
1946                ("HOME", home.to_str().unwrap()),
1947                ("CARGO_HOME", cargo.to_str().unwrap()),
1948            ]);
1949            container_run_args(
1950                &input,
1951                &ContainerSpec {
1952                    runtime,
1953                    network: None,
1954                    name: None,
1955                    image: DEFAULT_IMAGE.to_string(),
1956                },
1957                Path::new("sh"),
1958                &[
1959                    "-c".to_string(),
1960                    "printf 'ready\\n'; read -r proceed; test -s \"$1\" || exit 2; \
1961                     for secret in \"$2\" \"$3\" \"$4\" \"$5\" \"$6\" \"$7\"; do \
1962                     if cat \"$secret\"; then exit 3; fi; \
1963                     if printf forged > \"$secret\"; then exit 4; fi; done; \
1964                     if rm \"$9\"; then exit 5; fi; \
1965                     test \"$(cat \"$8\")\" = visible-policy || exit 8; \
1966                     printf work > \"$1-worker\""
1967                        .to_string(),
1968                    "test".to_string(),
1969                    session.join("host-witness").display().to_string(),
1970                    authority.display().to_string(),
1971                    global_config.display().to_string(),
1972                    repo_token_path.display().to_string(),
1973                    repo_read_token_path.display().to_string(),
1974                    repo_config.display().to_string(),
1975                    cargo_credentials.display().to_string(),
1976                    policy.display().to_string(),
1977                    home.join(".kranz").display().to_string(),
1978                ],
1979                None,
1980            )
1981        };
1982        // Keep Docker's HOME/context stable while other tests relocate HOME.
1983        let _env = crate::agent_env::EnvTestGuard::engage(&[]);
1984        let mut child = std::process::Command::new(runtime.binary())
1985            .args(args)
1986            .stdin(std::process::Stdio::piped())
1987            .stdout(std::process::Stdio::piped())
1988            .stderr(std::process::Stdio::piped())
1989            .spawn()
1990            .unwrap();
1991        let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1992        let mut line = String::new();
1993        stdout.read_line(&mut line).unwrap();
1994        if line != "ready\n" {
1995            let _ = child.kill();
1996            let output = child.wait_with_output().unwrap();
1997            panic!(
1998                "container did not start: {line:?}: {}",
1999                String::from_utf8_lossy(&output.stderr)
2000            );
2001        }
2002        // The host creates both the directory and its tokens after the worker
2003        // is running. A visible witness proves its ordinary bind is live.
2004        std::fs::create_dir(authority.parent().unwrap()).unwrap();
2005        std::fs::write(&authority, "fake-authority").unwrap();
2006        std::fs::write(&global_config, "fake-config").unwrap();
2007        for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2008            assert!(
2009                !path.exists(),
2010                "mount setup created a placeholder credential"
2011            );
2012            std::fs::write(path, "fake-authority").unwrap();
2013        }
2014        let rotated = session.join(".kranz/rotated.tmp");
2015        std::fs::write(&rotated, "rotated-token").unwrap();
2016        std::fs::rename(rotated, &repo_token_path).unwrap();
2017        std::fs::write(session.join("host-witness"), "visible").unwrap();
2018        child
2019            .stdin
2020            .take()
2021            .unwrap()
2022            .write_all(b"continue\n")
2023            .unwrap();
2024        let output = child.wait_with_output().unwrap();
2025        assert!(output.status.success(), "{output:?}");
2026        assert!(session.join("host-witness-worker").exists());
2027        assert!(
2028            home.join(".kranz").is_symlink(),
2029            "authority alias was replaced"
2030        );
2031        assert_eq!(
2032            std::fs::read_to_string(repo_token_path).unwrap(),
2033            "rotated-token"
2034        );
2035        for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2036            assert_eq!(std::fs::read_to_string(path).unwrap(), "fake-authority");
2037        }
2038        assert_eq!(
2039            std::fs::read_to_string(global_config).unwrap(),
2040            "fake-config"
2041        );
2042    }
2043}
2044
2045#[cfg(test)]
2046mod git_mount_tests {
2047    use super::*;
2048
2049    #[test]
2050    fn git_config_mount_nodes_preserve_existing_readonly_destinations() {
2051        let root = tempfile::tempdir().unwrap();
2052        let root = crate::sandbox::absolutize(root.path());
2053        let git = root.join(".git");
2054        std::fs::create_dir(&git).unwrap();
2055        std::fs::write(git.join("config"), "[core]\nrepositoryformatversion = 0\n").unwrap();
2056        let inputs = SandboxInputs {
2057            enforce: crate::types::SandboxEnforce::Fs,
2058            session_cwd: root.clone(),
2059            mission_dir: root.join(".kranz/missions/m-fixture"),
2060            tmpdir: root.join("scratch"),
2061            extra_write: Vec::new(),
2062            egress: Vec::new(),
2063            validator_read_deny_roots: Vec::new(),
2064        };
2065        let root = container_host_path(&root);
2066        let git = container_host_path(&git);
2067        let mut args = vec![
2068            "-v".into(),
2069            mount_arg(&root, false),
2070            "-v".into(),
2071            mount_arg(&git, true),
2072        ];
2073        push_authority_masks(&mut args, &inputs);
2074        let duplicates = args
2075            .windows(2)
2076            .filter(|part| {
2077                part[0] == "-v"
2078                    && (part[1] == mount_arg(&git, false) || part[1] == mount_arg(&git, true))
2079            })
2080            .count();
2081        assert_eq!(duplicates, 1, "{args:?}");
2082        assert!(args
2083            .windows(2)
2084            .any(|part| part[0] == "-v" && part[1] == mount_arg(&git, true)));
2085    }
2086}