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