Skip to main content

kranz_engine/
sandbox_container.rs

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