Skip to main content

kranz_engine/
sandbox_container.rs

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