Skip to main content

kranz_engine/
command_exec.rs

1//! Bounded, process-tree-killed shell execution for contract and merge-gate
2//! commands — extracted from `orchestrator.rs` in the monolith split (pure
3//! code motion, no behavior change). These are the ONLY places the engine
4//! runs user-authored shell: contract commands need real shell semantics
5//! (`sh -c` / `cmd /C`), so argument handling, timeout kill discipline
6//! (process group on unix, Job Object on Windows), output tailing, and
7//! environment sanitization live here as one unit.
8//!
9//! ## Sandbox wrap (ticket engine-gates-sandbox-wrapped)
10//!
11//! Env-clearing alone is not isolation: engine-run gates execute
12//! worker-authored build scripts and test binaries, and an env-cleared
13//! process still holds the engine's filesystem and network authority (the
14//! operator home is discoverable without `HOME` via pwent / `/Users/*`). When
15//! the mission's `worker.sandbox.enforce` is not `off`, the gate's `sh -c` is
16//! therefore wrapped in the SAME resolved profile an agent session would get
17//! — [`GateSandbox::Seatbelt`] (`sandbox-exec -f`) on macOS,
18//! [`GateSandbox::Bubblewrap`] on Linux, and [`GateSandbox::AppContainer`]
19//! on Windows — reusing `crate::sandbox`'s
20//! writable-root computation, mission-metadata write denies, and authority
21//! read denies. `enforce == off` (and the documented no-op postures below)
22//! keeps the pre-wrap behavior byte-for-byte.
23//!
24//! The gate profile's writable shape is the gate's cwd (the worktree —
25//! `target/` and everything else a build writes lives under it) plus a
26//! private scratch (validation/final gate: the mission's `runs/contract-home`
27//! the contract env already points HOME/TMPDIR/CARGO_HOME at; merge gate: a
28//! per-run self-cleaning `kranz-gate-*` temp root). The gate profile also
29//! appends one narrow extra the session profile lacks (see
30//! [`gate_profile_extras`] for the evidence): a `/dev/null` write allow
31//! (`deny default` otherwise rejects the redirects real gate scripts use
32//! liberally — this repo's gascity merge-gate scripts alone carry 148 of
33//! them). SBPL allows compose order-independently and denies still take
34//! precedence, so the append cannot weaken the generated profile; the
35//! agent-session profile itself is deliberately untouched.
36//!
37//! macOS xcrun posture (13th-pass review, P1 — prewarm + deny): the profile
38//! used to append a name-anchored `xcrun_db*` write regex over the Darwin
39//! per-user temp dir, because the xcrun shims behind `/usr/bin/git` et al.
40//! refresh their tool-resolution cache there via confstr, IGNORING TMPDIR,
41//! and a refresh under parallel spawns killed a wrapped `cargo test` with
42//! EPERM. But that regex also let a wrapped gate WRITE the shared per-user
43//! xcrun database — including the operator's existing one, a mutation
44//! surface outside the mission that later developer-tool invocations rely
45//! on. The regex is GONE: `prewarm_xcrun_cache_outside_sandbox` refreshes
46//! the cache OUTSIDE the sandbox once per resolve (cheap, bounded,
47//! failure-tolerant), and a shim refresh that still races stale inside the
48//! sandbox now fails loudly with the shim's own EPERM — a documented edge,
49//! never a silent hole. bwrap has no equivalent gap (`--dev /dev` covers
50//! device writes; Linux has no xcrun shim).
51//!
52//! Network posture: the profile's, mirroring sessions — `fs` keeps full
53//! egress (write containment is the fs-tier promise), `fs+net` cuts outbound
54//! TCP to loopback. Sessions escape loopback through the filtering egress
55//! proxy (`crate::egress_proxy`); engine-run gates are NOT wired through it —
56//! it is session infrastructure, and standalone merge gates run with no
57//! engine alive to host one — so an `fs+net` gate is offline-by-cache: the
58//! stage-1 seeded cache-only Cargo home is its registry, and
59//! `CARGO_NET_OFFLINE=true` is injected so a missing crate fails with a
60//! clear cargo error instead of a kernel-denied socket. Toolchains without a
61//! warm seeded cache (a cold `npm ci`) need `enforce: fs`.
62//!
63//! ## Container arm (ticket container-gate-wrapper)
64//!
65//! With `provider = "container"` and `enforce != off`, the gate command runs
66//! INSIDE the mission container instead of on the host beside the
67//! container-wrapped sessions: [`GateSandbox::Container`] builds a
68//! `container_gate_run_args` argv (the same `run --rm -i --read-only` shape
69//! agent sessions get — gate cwd rw, mission metadata ro, scratch rw,
70//! authority files /dev/null-masked) and executes it through the same
71//! bounded core, so timeout/tree-kill/drain discipline is identical. The
72//! deltas from the session argv: the payload is `sh -c <command>`, the
73//! container is NAMED so the timeout path can force-remove it (the bounded
74//! core's group SIGKILL reaches the runtime client, not the in-container
75//! tree — the daemon owns those processes), the gate's sanitized env crosses
76//! via `-e` flags (a runtime client forwards no env), and the real Cargo
77//! root is NEVER mounted (a credential directory; the gate's cache-only
78//! `CARGO_HOME` under the rw scratch is forwarded instead — only the
79//! credential-free `<cargo>/bin` shim dir crosses, alongside the read-only
80//! rustup toolchain + npm cache the gate's toolchain resolution needs).
81//! `fs+net` mirrors the session container's handling: empty egress →
82//! `--network none` (the hard boundary); a non-empty list FAILS CLOSED at
83//! resolve (no egress proxy exists engine-side, and the bridge would be
84//! advisory-only — `config::validate` already refuses the pair up front).
85//! A requested container with no runtime on PATH FAILS CLOSED at resolve,
86//! mirroring session resolution (`runner::resolve_sandbox_or_refuse`) —
87//! never a silent host-side gate under an enforced container config.
88//!
89//! Measured spawn cost (2026-08-03, macOS 15, M-series, Seatbelt; harness:
90//! `gate_sandbox_wrap_measure`). Per-spawn micro (`true`, 50 reps): 23.5ms
91//! unwrapped vs 26.0ms wrapped — +2.5ms/spawn (+10.8%; across four runs the
92//! absolute delta held at ~1.3–5.7ms). Real gate
93//! (`cargo test -p kranz-engine --lib` with the sandbox-hostile skips named
94//! in the harness, 2 reps, fresh cache-only Cargo home each rep): 97.7s
95//! unwrapped vs 96.2s wrapped mean — a −1.5% delta, i.e. NO measurable
96//! overhead at gate scale (noise; the ~2.5ms wrap cost vanishes against a
97//! ~97s gate). Nowhere near the ticket's ~20% opt-in threshold, so the wrap
98//! is the DEFAULT under `enforce != off`, not an opt-in.
99//!
100//! ## Gate supervision policy (ticket gate-sandbox-supervision-dogfood)
101//!
102//! The wrap's initial posture was session-parity for process supervision:
103//! `(allow signal (target self))`, no ps. kranz's OWN engine suite
104//! legitimately spawns and supervises children (the sandbox/kill machinery
105//! testing itself), so `cargo test --workspace` as a wrapped contract
106//! command failed 11 self-referential tests (probed 2026-08-03) — a kranz
107//! mission with process enforcement could not satisfy this repo's mandatory
108//! gate. The fix is a gate-SPECIFIC policy, never a global widening (the
109//! session profile generator is untouched; everything rides the
110//! [`gate_profile_extras`] append seam):
111//!
112//! - `(allow signal (target same-sandbox))`: the wrapped gate may signal
113//!   (kill / `kill(pid, 0)` / killpg) processes carrying its OWN sandbox
114//!   label instance — precisely its descendant tree, hereditary across
115//!   fork/exec — while launchd, unrelated same-uid host processes, and even
116//!   sibling `sandbox-exec` invocations with the identical profile stay
117//!   EPERM. Probe evidence is recorded in [`gate_profile_extras`].
118//! - `proc_pidinfo`-first identity tokens (event_log.rs): `/bin/ps` is
119//!   setuid root, and setuid exec is kernel-denied inside ANY sandbox
120//!   (probed 2026-08-05 — EPERM even under `(allow default)`; not
121//!   SBPL-expressible). The token path now reads `p_starttime` directly
122//!   (ungated for same-uid pids, byte-identical rendering to `ps -o
123//!   lstart=`), so lock-liveness probes work inside the wrap; the setuid ps
124//!   spawn remains as the fallback for other-uid pids (pid 1).
125//! - What NO policy can grant inside the wrap, so those suite tests skip
126//!   with the detectable `SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)`
127//!   marker instead: executing `/bin/ps` at all (the ps-fixture tests), and
128//!   nested `sandbox_apply` of any profile but the identical one (the
129//!   preflight/sandbox-enforcement tests — kernel-denied regardless of
130//!   SBPL content).
131//!
132//! The proving ground is a fixture, not a one-off:
133//! `gate_sandbox_wrap_dogfood_supervision_workspace_suite` (ignored; run by
134//! the `rust-macos-wrapped-suite` CI job) executes `cargo test --workspace`
135//! through the real wrap and asserts a green exit, reporting the
136//! skip-under-wrap marker count.
137
138use std::collections::HashMap;
139use std::time::Duration;
140use tokio::io::{AsyncRead, AsyncReadExt};
141
142/// Tail kept from a failed contract command's output.
143const COMMAND_OUTPUT_TAIL: usize = 1500;
144
145/// Hard cap on one contract `command` assertion at the final gate.
146const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
147
148/// Run `program args` to completion, polling with a bounded wall-clock
149/// (`timeout`) rather than blocking forever — the container provider's
150/// runtime probes (`workspace_container::spawn_bounded`) must never hang a
151/// readiness check. Returns `None` on spawn failure or on timeout (the child
152/// is killed). Synchronous and runtime-free so it is callable from inside the
153/// ambient tokio runtime; short-lived runtime probes only — anything that can
154/// spawn a tree of children or emit large output belongs on
155/// [`run_command_bounded`] (concurrent pipe drain + process-tree kill).
156pub(crate) fn run_with_timeout(
157    program: &std::path::Path,
158    args: &[String],
159    timeout: Duration,
160) -> Option<std::process::Output> {
161    let mut child = std::process::Command::new(program)
162        .args(args)
163        .stdin(std::process::Stdio::null())
164        .stdout(std::process::Stdio::piped())
165        .stderr(std::process::Stdio::piped())
166        .spawn()
167        .ok()?;
168    let start = std::time::Instant::now();
169    loop {
170        match child.try_wait() {
171            Ok(Some(_)) => return child.wait_with_output().ok(),
172            Ok(None) => {
173                if start.elapsed() >= timeout {
174                    let _ = child.kill();
175                    let _ = child.wait();
176                    return None;
177                }
178                std::thread::sleep(Duration::from_millis(20));
179            }
180            Err(_) => return None,
181        }
182    }
183}
184
185/// Last `max` characters of `text` (for stderr tails in sandbox preflight
186/// messages — never splits a code point).
187pub(crate) fn last_chars_local(text: &str, max: usize) -> String {
188    let chars: Vec<char> = text.chars().collect();
189    let start = chars.len().saturating_sub(max);
190    chars[start..].iter().collect()
191}
192
193/// Whether `root` looks like a git repository — a `.git` entry exists (a dir
194/// for a normal repo, a file for a worktree/submodule gitlink). Best-effort:
195/// only a plainly-absent `.git` produces the preflight error.
196pub(crate) fn is_git_repo(root: &std::path::Path) -> bool {
197    root.join(".git").exists()
198}
199
200/// Run one user-authored contract command line at the final gate.
201///
202/// DELIBERATE shell usage (the one place in the engine): contract commands
203/// are user-authored shell lines ("npm test -- --grep auth") that need real
204/// shell semantics — argument splitting here would corrupt them. `cmd /C` on
205/// Windows, `sh -c` elsewhere; cwd = repo root; 10-minute cap.
206///
207/// agent-env-clear: the shell spawns with a CLEARED environment — `env` is
208/// the child's COMPLETE environment, built by callers via
209/// [`crate::agent_env::contract_command_env`] (minimal allowlist +
210/// `KRANZ_BASE_SHA` + toolchain caches + any `contractEnvPassthrough`
211/// names). Ambient secrets never reach a contract command.
212///
213/// Test-only since `engine-gates-sandbox-wrapped`: production contract/gate
214/// execution goes through [`run_shell_command_sandboxed`] (whose
215/// [`GateSandbox::Disabled`] arm reproduces this path byte-for-byte — the
216/// off-regression tests compare against this reference implementation), and
217/// the workspace-gate trust channel uses
218/// [`run_shell_command_with_code_cleared`].
219///
220/// `all(test, unix)`: every caller is a unix-gated shell test — on Windows
221/// test builds the function is dead code and clippy's `-D warnings` gates
222/// it (run 30870594288).
223#[cfg(all(test, unix))]
224pub(crate) async fn run_shell_command(
225    cwd: &std::path::Path,
226    command: &str,
227    env: &HashMap<String, String>,
228) -> (bool, String) {
229    run_shell_command_with_timeout(cwd, command, COMMAND_TIMEOUT, env).await
230}
231
232/// `run_shell_command` plus the process exit code: `Some(0)` is success,
233/// `Some(n)` a real failure code, and `None` when the command never produced
234/// one (spawn failure, the timeout/group-kill path, or signal termination —
235/// in those cases the output string says which). The workspace bootstrap/
236/// readiness gate names the code in its block reasons so a blocked mission
237/// reads "exit code 3", not just "failed".
238///
239/// Test-only since the follow-up review's M-3: `disk.prune` was the last
240/// production caller of the INHERITED-env arm, and repo-authored
241/// `.kranz/workspace.json` commands have no business running with the
242/// engine's ambient credentials. Every contract-declared command lane now
243/// uses [`run_shell_command_with_code_cleared`]; this stays as the exit-code
244/// reference the shared plumbing is tested against. `cfg(test)` is what stops
245/// a future caller quietly reopening the inherited-env channel.
246#[cfg(test)]
247pub(crate) async fn run_shell_command_with_code(
248    cwd: &std::path::Path,
249    command: &str,
250    env: &HashMap<String, String>,
251) -> (Option<i32>, String) {
252    run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, false).await
253}
254
255/// [`run_shell_command_with_code`] with the environment CLEARED — `env` is
256/// the child's COMPLETE environment, the same trust channel every
257/// validation-contract command runs on.
258///
259/// All FOUR contract-declared command lanes use this — bootstrap, readiness,
260/// the golden-data hooks, and (since the follow-up review's M-3)
261/// `disk.prune`. They used to inherit the engine's whole ambient environment
262/// on the strength of a doc comment about the workspace contract's
263/// `secrets[]` list that nothing actually enforced (2026-09-01 adversarial
264/// audit, H4). Their env is built by
265/// `crate::workspace_gate::gate_command_env`, where a `secrets[]` name
266/// crosses only when the OPERATOR's `contractEnvPassthrough` names it too
267/// (follow-up review, H-6 — repo content must not choose which ambient
268/// credentials leave the host).
269pub(crate) async fn run_shell_command_with_code_cleared(
270    cwd: &std::path::Path,
271    command: &str,
272    env: &HashMap<String, String>,
273) -> (Option<i32>, String) {
274    run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, true).await
275}
276
277/// [`run_shell_command`] with an explicit timeout (separated so tests can
278/// exercise the timeout path without waiting ten minutes).
279///
280/// `clear_env` selects the trust channel: `true` for every command a
281/// contract can name — validation-contract commands and, since H4 and the
282/// follow-up review's M-3, all four workspace-contract lanes (the `env` map
283/// is then the child's COMPLETE environment — see [`run_shell_command`]).
284/// The `false` arm inherits the engine's ambient environment and has no
285/// production caller left; it survives only as the test reference for the
286/// exit-code plumbing.
287///
288/// Pipe draining and the process-tree timeout kill (unix process group,
289/// Windows Job Object) live in the one shared core, [`run_command_bounded`].
290///
291/// `all(test, unix)`: called only by [`run_shell_command`] and unix-gated
292/// timeout tests — dead code on Windows test builds (same clippy class).
293#[cfg(all(test, unix))]
294async fn run_shell_command_with_timeout(
295    cwd: &std::path::Path,
296    command: &str,
297    timeout: Duration,
298    env: &HashMap<String, String>,
299) -> (bool, String) {
300    let (code, output) = run_shell_command_with_timeout_env(cwd, command, timeout, env, true).await;
301    (code == Some(0), output)
302}
303
304async fn run_shell_command_with_timeout_env(
305    cwd: &std::path::Path,
306    command: &str,
307    timeout: Duration,
308    env: &HashMap<String, String>,
309    clear_env: bool,
310) -> (Option<i32>, String) {
311    let (program, args) = shell_argv(command);
312    let mut cmd = tokio::process::Command::new(program);
313    cmd.args(args);
314    if clear_env {
315        cmd.env_clear();
316    }
317    run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
318}
319
320/// The shell argv every contract/gate command bottoms out in (`cmd /C` on
321/// Windows, `sh -c` elsewhere), factored out of
322/// [`run_shell_command_with_timeout_env`] so [`GateSandbox::Disabled`]
323/// reproduces the pre-wrap invocation byte-for-byte.
324fn shell_argv(command: &str) -> (std::path::PathBuf, Vec<String>) {
325    #[cfg(windows)]
326    {
327        (
328            std::path::PathBuf::from("cmd"),
329            vec!["/C".to_string(), command.to_string()],
330        )
331    }
332    #[cfg(not(windows))]
333    {
334        (
335            std::path::PathBuf::from("sh"),
336            vec!["-c".to_string(), command.to_string()],
337        )
338    }
339}
340
341/// Bounded run of an arbitrary program argv with the same pipe-draining /
342/// process-tree-kill discipline as contract shell commands — the sandbox
343/// preflight probe (`sandbox-exec -f <profile> /bin/sh -c <command>`) runs
344/// here rather than through a spawner of its own. `env` is the child's
345/// COMPLETE environment (the process env is cleared first): probes are
346/// operator-authored contract commands, so they get exactly the contract env
347/// the final gate would give them — never ambient secrets.
348pub(crate) async fn run_bounded_argv(
349    cwd: &std::path::Path,
350    program: &std::path::Path,
351    args: &[String],
352    timeout: Duration,
353    env: &HashMap<String, String>,
354) -> (Option<i32>, String) {
355    let mut cmd = tokio::process::Command::new(program);
356    cmd.args(args);
357    cmd.env_clear();
358    run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
359}
360
361// ---------------------------------------------------------------------------
362// Gate sandbox wrap (ticket engine-gates-sandbox-wrapped) — see the module doc
363// ---------------------------------------------------------------------------
364
365/// The resolved sandbox posture for one engine-run gate execution context
366/// (validation-round contract commands, the final gate, merge gates).
367///
368/// [`GateSandbox::Disabled`] is the byte-identical pre-wrap behavior:
369/// `enforce == off` (the operator opted out; the cache-only `CARGO_HOME`
370/// still applies). Every enforced posture wraps: the process provider via
371/// [`GateSandbox::Seatbelt`] (`sandbox-exec -f`) on macOS,
372/// [`GateSandbox::Bubblewrap`] on Linux, or [`GateSandbox::AppContainer`] on
373/// Windows, reusing `crate::sandbox`'s
374/// writable-root computation, mission-metadata write denies, and authority
375/// read denies; the container provider via [`GateSandbox::Container`] (the
376/// mission container — ticket container-gate-wrapper). A platform
377/// [`crate::sandbox::platform_support`] cannot honor, tooling that is
378/// requested but missing (Linux without `bwrap`), and `provider: container`
379/// with no runtime on PATH all FAIL CLOSED at resolve time (13th-pass
380/// review, P1, and the container ticket: agent sessions already refuse to
381/// run there; a standalone merge gate must fail loudly too, never run
382/// unsandboxed under an enforced config).
383#[derive(Debug)]
384pub(crate) enum GateSandbox {
385    /// Run the shell exactly as before the wrap — no wrapper process.
386    Disabled,
387    /// macOS Seatbelt: `sandbox-exec -f <profile> /bin/sh -c <command>`.
388    Seatbelt {
389        enforce: crate::types::SandboxEnforce,
390        profile_path: std::path::PathBuf,
391    },
392    /// Linux bubblewrap: `bwrap <args> -- /bin/sh -c <command>`. The inputs
393    /// ride along because the argv — including its spawn-time mask-bind
394    /// preparation — is built per command.
395    Bubblewrap {
396        inputs: Box<crate::sandbox::SandboxInputs>,
397    },
398    /// Windows stable AppContainer launcher. One resolved posture owns one
399    /// disposable profile/ACL lease across its commands; every command still
400    /// gets a private launch plan and an independently supervised process.
401    AppContainer {
402        inputs: Box<crate::sandbox::SandboxInputs>,
403        #[cfg(windows)]
404        context: crate::appcontainer_windows::AppContainerLaunchContext,
405    },
406    /// Tier-3 container: `<runtime> run --rm -i --read-only --name <name> …
407    /// <image> sh -c <command>` (ticket container-gate-wrapper). Inputs and
408    /// spec ride along because the argv — the gate's sanitized env included
409    /// — is built per command.
410    Container {
411        inputs: Box<crate::sandbox::SandboxInputs>,
412        spec: crate::sandbox_container::ContainerSpec,
413    },
414}
415
416/// The `(program, args)` a resolved gate sandbox produces for one command,
417/// plus the best-effort teardown the runner issues when the command did not
418/// exit on its own.
419pub(crate) struct WrappedCommand {
420    pub program: std::path::PathBuf,
421    pub args: Vec<String>,
422    /// `<runtime> rm -f <name>` for the container arm: the bounded core's
423    /// timeout SIGKILL reaches the runtime CLIENT's process group, but the
424    /// in-container tree belongs to the daemon and can outlive the client
425    /// (a parked `sleep 300` gate would otherwise run on, holding the rw
426    /// mounts, until its command exits naturally). Force-removing the named
427    /// container kills that tree. `None` for the process-sandbox arms —
428    /// there the group SIGKILL IS the tree kill. Best-effort: a teardown
429    /// failure (the runtime already reaped the container, an unsupported
430    /// `rm -f`) is ignored, and `--rm` still reaps every normal exit.
431    pub timeout_teardown: Option<(std::path::PathBuf, Vec<String>)>,
432    /// Retains ownership of the resolved posture's disposable profile and
433    /// no-follow DACL handles through the wrapper process. Callers still must
434    /// await/reap the command before explicitly cleaning the posture. Absent
435    /// on non-Windows builds.
436    #[cfg(windows)]
437    _appcontainer_context: Option<crate::appcontainer_windows::AppContainerLaunchContext>,
438}
439
440impl GateSandbox {
441    /// Controls execute from a read-only checkout while the ordinary gate
442    /// posture's cwd denotes writable scratch. Keep the mount inputs intact;
443    /// the inner shell changes directory only after entering containment.
444    #[cfg(any(target_os = "macos", target_os = "linux", test))]
445    fn wrap_control_shell(
446        &self,
447        cwd: &std::path::Path,
448        command: &str,
449        env: &HashMap<String, String>,
450    ) -> crate::error::Result<WrappedCommand> {
451        match self {
452            Self::Seatbelt { .. } => self.wrap_shell(command, env),
453            Self::Bubblewrap { inputs } => Ok(WrappedCommand {
454                program: "bwrap".into(),
455                args: crate::sandbox::bubblewrap_args(
456                    inputs,
457                    std::path::Path::new("/bin/sh"),
458                    &[
459                        "-c".into(),
460                        "cd -- \"$1\" && exec /bin/sh -c \"$2\"".into(),
461                        "kranz-control".into(),
462                        cwd.display().to_string(),
463                        command.into(),
464                    ],
465                )?,
466                timeout_teardown: None,
467                #[cfg(windows)]
468                _appcontainer_context: None,
469            }),
470            _ => Err(crate::error::EngineError::Config(
471                "negative controls require native macOS/Linux containment".into(),
472            )),
473        }
474    }
475
476    /// The enforcement level the wrap applies (`Off` when disabled) — the
477    /// runner keys the fs+net offline-by-cache env adjustment on it.
478    pub(crate) fn enforce(&self) -> crate::types::SandboxEnforce {
479        match self {
480            GateSandbox::Disabled => crate::types::SandboxEnforce::Off,
481            GateSandbox::Seatbelt { enforce, .. } => *enforce,
482            GateSandbox::Bubblewrap { inputs } => inputs.enforce,
483            GateSandbox::AppContainer { inputs, .. } => inputs.enforce,
484            GateSandbox::Container { inputs, .. } => inputs.enforce,
485        }
486    }
487
488    /// Explicitly retire host state owned by a resolved posture. Most
489    /// providers have nothing to release; Windows AppContainer must surface
490    /// temporary ACL/profile cleanup failures before a mission can pass.
491    pub(crate) fn cleanup(&mut self) -> crate::error::Result<()> {
492        #[cfg(windows)]
493        if let GateSandbox::AppContainer { context, .. } = self {
494            return context.cleanup();
495        }
496        Ok(())
497    }
498
499    /// Build the [`WrappedCommand`] that runs `command` under this posture.
500    /// `Disabled` reproduces [`shell_argv`] EXACTLY, so the off path is
501    /// byte-identical to the pre-wrap behavior. A bubblewrap mask-prep
502    /// failure FAILS CLOSED — a gate that cannot be wrapped must not run
503    /// unsandboxed under enforcement.
504    ///
505    /// `env` is the gate's FINAL (already sanitized, offline-adjusted)
506    /// environment: the process-sandbox arms ignore it (their child inherits
507    /// it from the bounded runner), but the container arm must bake it into
508    /// the argv as `-e` flags — a runtime client forwards no env into the
509    /// container.
510    fn wrap_shell(
511        &self,
512        command: &str,
513        env: &HashMap<String, String>,
514    ) -> crate::error::Result<WrappedCommand> {
515        match self {
516            GateSandbox::Disabled => {
517                let (program, args) = shell_argv(command);
518                Ok(WrappedCommand {
519                    program,
520                    args,
521                    timeout_teardown: None,
522                    #[cfg(windows)]
523                    _appcontainer_context: None,
524                })
525            }
526            GateSandbox::Seatbelt { profile_path, .. } => {
527                let (program, args) = crate::backend_claude::sandbox_command(
528                    profile_path,
529                    std::path::Path::new("/bin/sh"),
530                    &["-c".to_string(), command.to_string()],
531                );
532                Ok(WrappedCommand {
533                    program,
534                    args,
535                    timeout_teardown: None,
536                    #[cfg(windows)]
537                    _appcontainer_context: None,
538                })
539            }
540            GateSandbox::Bubblewrap { inputs } => {
541                let args = crate::sandbox::bubblewrap_args(
542                    inputs,
543                    std::path::Path::new("/bin/sh"),
544                    &["-c".to_string(), command.to_string()],
545                )?;
546                Ok(WrappedCommand {
547                    program: std::path::PathBuf::from("bwrap"),
548                    args,
549                    timeout_teardown: None,
550                    #[cfg(windows)]
551                    _appcontainer_context: None,
552                })
553            }
554            GateSandbox::AppContainer {
555                inputs,
556                #[cfg(windows)]
557                context,
558            } => {
559                #[cfg(windows)]
560                {
561                    let (program, args) = shell_argv(command);
562                    let prepared = crate::appcontainer_windows::prepare_launch_in_context(
563                        context, inputs, &program, &args, env,
564                    )?;
565                    Ok(WrappedCommand {
566                        program: prepared.program,
567                        args: prepared.args,
568                        timeout_teardown: None,
569                        _appcontainer_context: Some(context.clone()),
570                    })
571                }
572                #[cfg(not(windows))]
573                {
574                    let _ = (inputs, command, env);
575                    Err(crate::error::EngineError::Backend(
576                        "AppContainer gate wrapper is unavailable on this host".to_string(),
577                    ))
578                }
579            }
580            GateSandbox::Container { inputs, spec } => {
581                // Named per command (never per resolve): parallel gate
582                // commands from one resolution must not collide on the name,
583                // and the teardown below targets exactly this container.
584                let name = format!("kranz-gate-{}", uuid::Uuid::new_v4().simple());
585                let args = crate::sandbox_container::container_gate_run_args(
586                    inputs, spec, command, env, &name,
587                );
588                Ok(WrappedCommand {
589                    program: std::path::PathBuf::from(spec.runtime.binary()),
590                    args,
591                    timeout_teardown: Some((
592                        std::path::PathBuf::from(spec.runtime.binary()),
593                        vec!["rm".to_string(), "-f".to_string(), name],
594                    )),
595                    #[cfg(windows)]
596                    _appcontainer_context: None,
597                })
598            }
599        }
600    }
601}
602
603/// The outcome of resolving a gate sandbox: the posture plus an optional
604/// operator-facing note (surfaced as an orchestrator decision / merge log
605/// line) should a future posture degrade to a no-op. Every CURRENT posture
606/// either wraps (`note: None`) or fails closed at resolve (an Err naming the
607/// missing support — an unsupported platform, linux without `bwrap`,
608/// `provider:container` without a runtime): the note seam is kept so a
609/// degraded no-op can never return SILENTLY — a posture that adds one must
610/// also teach the callers to surface it.
611#[derive(Debug)]
612pub(crate) struct GateSandboxResolution {
613    pub sandbox: GateSandbox,
614    pub note: Option<String>,
615    /// Whether the xcrun prewarm ran during THIS resolve (macOS Seatbelt
616    /// arm only; always false elsewhere). Per-resolution state, so the
617    /// once-per-resolve contract is assertable without a global counter —
618    /// a process-wide counter races with parallel test threads resolving
619    /// concurrently (rust-macos CI flake, run 30935850957). Read only by
620    /// the macOS-gated test; everywhere else the field exists only to keep
621    /// the resolution's shape platform-uniform.
622    #[cfg_attr(not(all(test, target_os = "macos")), allow(dead_code))]
623    pub prewarmed_xcrun: bool,
624}
625
626/// SBPL appended to the SESSION profile for gate use — never edited into
627/// `crate::sandbox::generate_profile` (the agent-session profile is
628/// deliberately untouched). SBPL allows compose order-independently and
629/// denies still take precedence regardless of clause order (verified with
630/// sandbox-exec), so appending cannot weaken the generated profile.
631///
632/// `(literal "/dev/null")` write allow: `deny default` otherwise rejects
633/// `/dev/null` redirects (probed 2026-08-03: "Operation not permitted"),
634/// which real gate lines and scripts use liberally (this repo's gascity
635/// merge-gate scripts: 148 hits in one file).
636///
637/// 13th-pass review (P1): the macOS `xcrun_db*` write regex this function
638/// used to append is GONE. It covered the shim cache refresh (see the
639/// module doc), but it also let a wrapped gate WRITE the shared per-user
640/// xcrun database — including the operator's existing one, a mutation
641/// surface outside the mission. The replacement posture is prewarm + deny:
642/// `prewarm_xcrun_cache_outside_sandbox` refreshes the cache unsandboxed
643/// once per resolve, and a shim refresh that still races stale inside the
644/// sandbox fails loudly with the shim's own EPERM (the documented edge).
645///
646/// `(allow signal (target same-sandbox))` — the gate-SPECIFIC supervision
647/// policy (ticket gate-sandbox-supervision-dogfood). A wrapped gate runs
648/// worker-authored build/test trees that legitimately spawn and supervise
649/// their own descendants (timeout kills, process-group SIGKILL, `kill(pid,
650/// 0)` liveness polls — kranz's OWN engine suite exercises exactly this, and
651/// under the session parity clause `(allow signal (target self))` every one
652/// of those probes is EPERM, so a kranz mission with process enforcement
653/// could not satisfy this repo's mandatory `cargo test --workspace` gate).
654/// `same-sandbox` scopes the allowance to processes carrying the SAME
655/// sandbox label instance — precisely the wrapped tree (the label is
656/// inherited across fork/exec and cannot be shed: applying a DIFFERENT
657/// profile from inside is kernel-denied, so the posture is hereditary).
658/// Probe evidence (2026-08-05, macOS 26.5.2, arm64, sandbox-exec):
659///
660/// - `kill`/`kill(pid, 0)`/`killpg` against children AND grandchildren
661///   (the `sh -c` → background-child timeout-kill shape): allowed.
662/// - `kill(pid, 0)` on a reaped child reports ESRCH, not EPERM, so
663///   liveness-poll loops terminate correctly.
664/// - launchd (pid 1), an unrelated same-uid host process, and a SIBLING
665///   `sandbox-exec` invocation launched with the identical profile file:
666///   all still EPERM — the scope is the sandbox instance (the tree), never
667///   the profile content and never host-wide.
668/// - `(target children)` was rejected as too narrow (direct children only;
669///   grandchildren stay EPERM) and `(target others)` buys nothing (host
670///   probes stay EPERM under it too) — `same-sandbox` is the only target
671///   that covers exactly the descendant tree.
672/// - What NO profile rule can grant (recorded so the gap is never
673///   re-probed blindly): executing `/bin/ps` (setuid root on this host's
674///   macOS — setuid exec is kernel-denied under ANY sandbox, even
675///   `(allow default)`; a copied binary is AMFI-killed) and applying a
676///   DIFFERENT nested profile (`sandbox_apply` EPERM regardless of
677///   `process-exec` allowances; re-applying the IDENTICAL profile is a
678///   permitted no-op). The suite's ps-fixture and nested-sandbox tests
679///   therefore carry explicit skip-under-wrap markers instead — see the
680///   module doc's supervision section. Process-info READS (`proc_pidinfo`)
681///   were never sandbox-gated for same-uid targets and keep working under
682///   `deny default` with no allowance at all (probed); only `/bin/ps`
683///   itself is unreachable.
684fn gate_profile_extras() -> String {
685    // The pty device surface, probed 2026-08-06 under sandbox-exec (the
686    // wrapped-suite failure: the three pty-driving tests died "out of pty
687    // devices" inside the gate wrap). macOS pty allocation needs THREE
688    // things the session profile's deny-default rejects: read+write on
689    // /dev/ptmx (the multiplexer), read+write on the allocated slave node
690    // (this host's pool names are BOTH /dev/tty[p-t]<hex> and the longer
691    // /dev/ttysNNN — hence the `+`), and the grantpt/unlockpt ioctls —
692    // `file-ioctl` is required for those two (proven: with it the whole
693    // posix_openpt -> grantpt -> unlockpt -> ptsname -> slave-open chain
694    // works; without it both ioctls EPERM). No ptmx, no pty: the harness
695    // is validator tooling that deserves the same gate the rest of the
696    // wrapped suite gets, not a skip.
697    //
698    // 14th-pass review (ticket gate-wrap-file-ioctl-unscoped): the ioctl
699    // allow is SCOPED to exactly that pty surface — /dev/ptmx plus the
700    // tty-slave regex — never the unrestricted `(allow file-ioctl)` every
701    // wrapped gate used to get (an unscoped allow lets worker-authored gate
702    // code ioctl any device it can open: terminal injection into the
703    // operator's tty, TIOCSTI-class surfaces, disk ioctls). Re-probed
704    // 2026-08-09 under sandbox-exec on macOS (arm64): the scoped shape
705    // passes the full openpty + termios + TIOCSWINSZ + read/write chain
706    // (PTY-OK, slave /dev/ttys003), and dropping the ioctl line entirely
707    // EPERMs at openpty — the scoped filter is what the chain needs, no
708    // more. The gate profile cannot know at resolve time whether the
709    // contract carries pty assertions (merge gates never see one), so the
710    // scoped lines ride every wrapped gate — the surface they open is the
711    // pty device pair and nothing else.
712    //
713    // 2026-09-01 adversarial audit (H7): the scoped regex still matched the
714    // OPERATOR'S OWN terminal. On macOS the pty slave pool IS the terminal
715    // pool — a Terminal.app session is `/dev/ttys003`, matched by
716    // `^/dev/tty[p-t][0-9a-f]+$` (`s` is in `[p-t]`) — so the narrowing did
717    // not exclude the very thing its comment names. A repo-authored gate
718    // command could open that node, write raw escape sequences to it, or
719    // `ioctl(TIOCSTI)` characters into the operator's shell, which the shell
720    // executes once `kranz` returns: arbitrary execution as the operator,
721    // outside the sandbox.
722    //
723    // The device-class allow stays (openpty needs it, and the profile cannot
724    // know which slave the harness will be handed), and the operator's own
725    // controlling terminal is DENIED by name after it —
726    // `crate::sandbox::operator_tty_paths` resolves the engine's fds 0/1/2.
727    // SBPL denies beat allows regardless of clause order, so the deny wins
728    // over the regex above; emitting it last is documentary. Nothing extra
729    // is emitted when the engine has no controlling terminal (a daemon, CI,
730    // `kranz serve`) — there is then no operator tty to protect, and every
731    // pty the harness allocates for itself stays reachable either way.
732    let mut extras = String::from(
733        "\n(allow file-write* (literal \"/dev/null\") (literal \"/dev/ptmx\"))\n\
734         (allow file-read* (literal \"/dev/ptmx\"))\n\
735         (allow file-read* file-write* (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
736         (allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
737         (allow signal (target same-sandbox))\n",
738    );
739    extras.push_str(&crate::sandbox::tty_deny_block(
740        &crate::sandbox::operator_tty_paths(),
741    ));
742    extras
743}
744
745/// Refresh the xcrun shims' tool-resolution cache OUTSIDE the sandbox, once
746/// per gate-profile resolve (13th-pass review, P1 — prewarm + deny): the
747/// gate profile no longer permits `xcrun_db` writes (see
748/// [`gate_profile_extras`]), so the `/usr/bin/*` shims (git, clang, …)
749/// behind a wrapped gate must find their cache FRESH in the Darwin per-user
750/// temp dir (which they locate via confstr, IGNORING TMPDIR).
751///
752/// Per resolve, NOT per command: the cache is per-user and shared, so one
753/// refresh covers every wrapped spawn the resolution produces. The probe is
754/// `git --version` through the operator's PATH — on a stock macOS that IS
755/// the `/usr/bin` shim, so the probe refreshes exactly the cache the gate's
756/// shims consult. Bounded (10s), output discarded, spawn/exit status
757/// ignored: a failed prewarm (no git, no dev tools, a shim that errors)
758/// leaves the deny posture in force and the gate still runs — it just might
759/// hit the loud edge (a stale-cache refresh inside the sandbox is EPERM,
760/// surfaced as the shim's own error). That edge, and a brew-first PATH
761/// whose `git` is not the shim, are the documented limits of the prewarm.
762#[cfg(target_os = "macos")]
763pub(crate) fn prewarm_xcrun_cache_outside_sandbox() {
764    let _ = run_with_timeout(
765        std::path::Path::new("git"),
766        &["--version".to_string()],
767        Duration::from_secs(10),
768    );
769}
770
771/// The ONE note for the container provider's runtime-unavailable posture,
772/// shared by [`resolve_gate_sandbox_target`] (whose Err the engine paths
773/// surface — the gate refuses to run) and
774/// [`MergeGatePolicy::degradation_note`] (which the server's merge path logs
775/// once — it has no event log, and the gate run itself then fails closed at
776/// resolve). The text must match on every path so an operator sees the SAME
777/// explanation wherever the gate ran. Ticket container-gate-wrapper wraps
778/// engine-run gates in the mission container whenever a runtime is detected;
779/// this note is the fail-closed remainder: with no runtime on PATH the gate
780/// must NOT degrade to a silent host-side run — container SESSIONS already
781/// refuse to run unsandboxed there (`runner::resolve_sandbox_or_refuse`),
782/// and engine-run gates mirror that posture.
783fn container_gate_note(enforce: crate::types::SandboxEnforce) -> String {
784    format!(
785        "sandbox provider:container with enforce:{} wraps engine-run gates in the mission \
786         container, but no container runtime (docker/podman/nerdctl/container) was found on \
787         PATH; refusing to run engine-run gates unsandboxed (fail closed, mirroring container \
788         session resolution) — install a runtime or set worker.sandbox.provider to \"process\"",
789        enforce.as_str()
790    )
791}
792
793/// Resolve the sandbox posture for one engine-run gate execution context.
794///
795/// `gate_cwd` is the gate's working directory AND the profile's writable
796/// root — it fills the session profile's
797/// [`crate::sandbox::SandboxInputs::session_cwd`] slot so the writable-root
798/// computation (`target/` and everything else a build writes lives under the
799/// gate tree) is REUSED, never re-rolled. `scratch_home` is the gate's
800/// private writable scratch: the mission's `runs/contract-home` for
801/// validation/final gates (the contract env already points
802/// HOME/TMPDIR/CARGO_HOME there), a per-run temp root for merge gates.
803/// `profile_dir` is where the Seatbelt profile file is written (gitignored
804/// scratch — `runs/` for the engine paths, the per-run scratch for merges).
805/// Mission metadata write-denies and authority read-denies come from
806/// `mission_dir`, exactly as sessions derive them.
807pub(crate) fn resolve_gate_sandbox(
808    sandbox_cfg: &crate::types::SandboxConfig,
809    gate_cwd: &std::path::Path,
810    mission_dir: &std::path::Path,
811    scratch_home: &std::path::Path,
812    profile_dir: &std::path::Path,
813) -> crate::error::Result<GateSandboxResolution> {
814    let runtime = crate::sandbox_container::detect();
815    resolve_gate_sandbox_target(
816        sandbox_cfg,
817        gate_cwd,
818        mission_dir,
819        scratch_home,
820        profile_dir,
821        std::env::consts::OS,
822        crate::sandbox::command_available("bwrap"),
823        runtime,
824        // A gate mounts the same roots the session does. Without this a
825        // proven host would run its worker contained and then refuse its own
826        // merge gate, failing the mission at the last step for a reason that
827        // no longer applied.
828        crate::sandbox::session_mount_proof(sandbox_cfg, gate_cwd, mission_dir, runtime),
829    )
830}
831
832/// The gate-shaped [`crate::sandbox::SandboxInputs`], shared by every
833/// enforced provider arm: the gate cwd fills the session profile's
834/// `session_cwd` slot so the writable-root computation is REUSED, never
835/// re-rolled; mission metadata write-denies and authority read-denies derive
836/// from `mission_dir` exactly as sessions derive them; the validator
837/// read-deny set is the validator-session wrap's, never a gate's (gates work
838/// IN the real tree).
839fn gate_sandbox_inputs(
840    sandbox_cfg: &crate::types::SandboxConfig,
841    gate_cwd: &std::path::Path,
842    mission_dir: &std::path::Path,
843    scratch_home: &std::path::Path,
844) -> crate::sandbox::SandboxInputs {
845    crate::sandbox::SandboxInputs {
846        enforce: sandbox_cfg.enforce,
847        session_cwd: gate_cwd.to_path_buf(),
848        mission_dir: mission_dir.to_path_buf(),
849        tmpdir: scratch_home.to_path_buf(),
850        extra_write: sandbox_cfg
851            .extra_write
852            .iter()
853            .map(|raw| crate::sandbox::expand_tilde(raw))
854            .collect(),
855        egress: sandbox_cfg.egress.clone(),
856        validator_read_deny_roots: Vec::new(),
857    }
858}
859
860/// [`resolve_gate_sandbox`] parameterized on the target OS, bwrap
861/// availability, and container runtime so the decision matrix is testable
862/// cross-platform (mirrors `crate::sandbox::resolve_for_session_target`).
863#[allow(clippy::too_many_arguments)]
864fn resolve_gate_sandbox_target(
865    sandbox_cfg: &crate::types::SandboxConfig,
866    gate_cwd: &std::path::Path,
867    mission_dir: &std::path::Path,
868    scratch_home: &std::path::Path,
869    profile_dir: &std::path::Path,
870    target_os: &str,
871    bwrap_available: bool,
872    container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
873    container_mount_proof: Option<crate::sandbox_container::MountProof>,
874) -> crate::error::Result<GateSandboxResolution> {
875    use crate::types::{SandboxEnforce, SandboxProvider};
876    let disabled = |note: Option<String>| {
877        Ok(GateSandboxResolution {
878            sandbox: GateSandbox::Disabled,
879            note,
880            prewarmed_xcrun: false,
881        })
882    };
883    if sandbox_cfg.enforce == SandboxEnforce::Off {
884        return disabled(None);
885    }
886    crate::sandbox::validate_git_config_protection(
887        &gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home),
888        sandbox_cfg.provider == SandboxProvider::Container || target_os == "linux",
889    )?;
890    if sandbox_cfg.provider == SandboxProvider::Container {
891        // Ticket container-gate-wrapper: engine-run gates join the agent
892        // sessions INSIDE the mission container. The fail postures mirror
893        // session resolution exactly (`sandbox::resolve_container_target` +
894        // `runner::resolve_sandbox_or_refuse`): a requested container with
895        // no runtime on PATH is refused — never a silent host-side gate.
896        // The container argv/mount contract is release-supported only on
897        // Linux. A macOS operator receipt exists, but hosted macOS cannot
898        // renew it as a CI release gate; Windows does not honor the POSIX
899        // guest-path and `/dev/null` authority-mask contract. Refuse before
900        // constructing an unverified gate command; session resolution applies
901        // the identical posture. macOS uses native Seatbelt instead.
902        if target_os == "windows" {
903            return Err(crate::error::EngineError::Config(format!(
904                "sandbox provider:container with enforce:{} is not supported on target_os=windows: the shipped contract uses POSIX guest paths, Linux images, and /dev/null authority masks that Windows containers do not honor; refusing to run engine-run gates under an unverified container mount contract",
905                sandbox_cfg.enforce.as_str()
906            )));
907        }
908        if target_os != "linux" {
909            match container_mount_proof {
910                Some(crate::sandbox_container::MountProof::Proven) => {}
911                Some(crate::sandbox_container::MountProof::Failed(reason)) => {
912                    return Err(crate::error::EngineError::Config(format!(
913                        "sandbox provider:container with enforce:{} refused for engine-run gates on target_os={target_os}: {reason}",
914                        sandbox_cfg.enforce.as_str()
915                    )));
916                }
917                None => {
918                    return Err(crate::error::EngineError::Config(format!(
919                        "sandbox provider:container with enforce:{} on target_os={target_os} requires a bind-mount proof on this host and none was taken; refusing to run engine-run gates under an unverified container mount contract; use sandbox.provider=\"process\" for native host containment",
920                        sandbox_cfg.enforce.as_str()
921                    )));
922                }
923            }
924        }
925        let Some(runtime) = container_runtime else {
926            return Err(crate::error::EngineError::Config(container_gate_note(
927                sandbox_cfg.enforce,
928            )));
929        };
930        // `fs+net` with a non-empty egress list is proxy-env advisory on the
931        // runtime bridge (no hard boundary), and engine-run gates are never
932        // wired through the egress proxy (session infrastructure — see the
933        // module doc). `config::validate` refuses the pair up front
934        // (`SandboxProvider::enforces_hard_net_boundary`); this resolve
935        // refuses it again so a standalone merge gate can never silently
936        // bridge either.
937        if sandbox_cfg.enforce == SandboxEnforce::FsNet
938            && !sandbox_cfg
939                .provider
940                .enforces_hard_net_boundary(&sandbox_cfg.egress)
941        {
942            return Err(crate::error::EngineError::Config(
943                "sandbox provider:container with enforce:fs+net and a non-empty egress list is \
944                 advisory-only for engine-run gates (no egress proxy exists engine-side); use an \
945                 empty egress list (the hard `--network none` boundary) or sandbox.provider \
946                 \"process\" — refusing to run engine-run gates with an advisory boundary"
947                    .to_string(),
948            ));
949        }
950        return Ok(GateSandboxResolution {
951            sandbox: GateSandbox::Container {
952                inputs: Box::new(gate_sandbox_inputs(
953                    sandbox_cfg,
954                    gate_cwd,
955                    mission_dir,
956                    scratch_home,
957                )),
958                spec: crate::sandbox_container::ContainerSpec {
959                    runtime,
960                    image: sandbox_cfg
961                        .image
962                        .clone()
963                        .unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
964                    network: None,
965                    name: None,
966                },
967            },
968            note: None,
969            prewarmed_xcrun: false,
970        });
971    }
972    match crate::sandbox::platform_support(sandbox_cfg.enforce, target_os) {
973        // Unreachable (Off returns above) — platform_support is the shared
974        // vocabulary, so the match stays exhaustive anyway.
975        crate::sandbox::SandboxDecision::Off => disabled(None),
976        // 13th-pass review (P1): FAIL CLOSED. Agent sessions already refuse
977        // to run unsandboxed on an unsupported platform; a standalone merge
978        // gate that resolved to Disabled here ran worker-authored code
979        // unsandboxed under an enforced config — loudly is the only honest
980        // posture.
981        crate::sandbox::SandboxDecision::UnsupportedWarn => {
982            Err(crate::error::EngineError::Config(format!(
983                "sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing \
984                 to run engine-run gates unsandboxed",
985                sandbox_cfg.enforce.as_str()
986            )))
987        }
988        crate::sandbox::SandboxDecision::Enforce(crate::sandbox::SandboxBackend::Bubblewrap)
989            if !bwrap_available =>
990        {
991            Err(crate::error::EngineError::Config(format!(
992                "sandbox enforce:{} requested on linux but `bwrap` was not found; refusing \
993                 to run engine-run gates unsandboxed",
994                sandbox_cfg.enforce.as_str()
995            )))
996        }
997        crate::sandbox::SandboxDecision::Enforce(backend) => {
998            let inputs = gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home);
999            match backend {
1000                crate::sandbox::SandboxBackend::Seatbelt => {
1001                    // 13th-pass (P1): the profile no longer permits xcrun_db
1002                    // writes, so refresh the shim cache OUTSIDE the sandbox
1003                    // once per resolve — never per command (the cache is
1004                    // per-user and shared; see prewarm's doc).
1005                    #[cfg(target_os = "macos")]
1006                    prewarm_xcrun_cache_outside_sandbox();
1007                    // The session profile PLUS the gate-specific extras (see
1008                    // [`gate_profile_extras`]) — appended, never edited in,
1009                    // so the session generator stays untouched.
1010                    let mut profile = crate::sandbox::generate_profile(&inputs);
1011                    profile.push_str(&gate_profile_extras());
1012                    let profile_path = crate::sandbox::write_profile_file(profile_dir, &profile)?;
1013                    Ok(GateSandboxResolution {
1014                        sandbox: GateSandbox::Seatbelt {
1015                            enforce: sandbox_cfg.enforce,
1016                            profile_path,
1017                        },
1018                        note: None,
1019                        // The prewarm ran above (macOS-only call site).
1020                        prewarmed_xcrun: cfg!(target_os = "macos"),
1021                    })
1022                }
1023                crate::sandbox::SandboxBackend::Bubblewrap => Ok(GateSandboxResolution {
1024                    sandbox: GateSandbox::Bubblewrap {
1025                        inputs: Box::new(inputs),
1026                    },
1027                    note: None,
1028                    prewarmed_xcrun: false,
1029                }),
1030                crate::sandbox::SandboxBackend::AppContainer => Ok(GateSandboxResolution {
1031                    sandbox: GateSandbox::AppContainer {
1032                        inputs: Box::new(inputs),
1033                        #[cfg(windows)]
1034                        context: crate::appcontainer_windows::new_launch_context(),
1035                    },
1036                    note: None,
1037                    prewarmed_xcrun: false,
1038                }),
1039                // platform_support never selects Container (that resolution
1040                // is `resolve_container_target`'s, and the provider check
1041                // above already returned) — the match stays exhaustive.
1042                crate::sandbox::SandboxBackend::Container => {
1043                    unreachable!("container provider returned above")
1044                }
1045            }
1046        }
1047    }
1048}
1049
1050/// The env a sandboxed gate actually runs with: the caller's contract/gate
1051/// env, plus — under `fs+net` ONLY — `CARGO_NET_OFFLINE=true`. The wrapped
1052/// gate's network posture is the profile's (`fs`: full egress; `fs+net`:
1053/// loopback-only), and engine-run gates are not wired through the egress
1054/// proxy (session infrastructure — see the module doc), so an `fs+net` gate
1055/// is offline-by-cache: the explicit offline flag turns a missing crate into
1056/// a clear cargo error instead of a kernel-denied socket.
1057pub(crate) fn gate_env_for_sandbox(
1058    env: &HashMap<String, String>,
1059    sandbox: &GateSandbox,
1060) -> HashMap<String, String> {
1061    let mut env = env.clone();
1062    if sandbox.enforce() == crate::types::SandboxEnforce::FsNet {
1063        env.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
1064    }
1065    env
1066}
1067
1068/// Prepare one gate command for execution OUTSIDE the bounded runner — the
1069/// pty harness (ticket `pty-functional-validation`) drives the wrapped argv
1070/// interactively, so it needs exactly what the bounded path computes per
1071/// command: the FINAL env (the fs+net offline-by-cache adjustment included,
1072/// which the container arm bakes into the argv) and the sandbox wrap (or its
1073/// fail-closed error). Keeping the pair computed here, in one place, means
1074/// a pty-driven assertion can never drift from the posture a bounded
1075/// contract command would get for the same command line.
1076pub(crate) fn prepare_gate_command(
1077    command: &str,
1078    env: &HashMap<String, String>,
1079    sandbox: &GateSandbox,
1080) -> crate::error::Result<(WrappedCommand, HashMap<String, String>)> {
1081    let env = gate_env_for_sandbox(env, sandbox);
1082    let wrapped = sandbox.wrap_shell(command, &env)?;
1083    Ok((wrapped, env))
1084}
1085
1086/// The pre-wrap contract-command runner under a resolved gate sandbox:
1087/// validation-round contract commands, the final gate, and pack gates run
1088/// through here. [`GateSandbox::Disabled`] reproduces the pre-wrap `sh -c`
1089/// behavior byte-for-byte;
1090/// an enforced posture wraps the SAME `sh -c` in the resolved profile (or the
1091/// mission container — ticket container-gate-wrapper), and the wrapper still
1092/// leads the SAME new process group
1093/// ([`configure_bounded_child`]) — so the bounded core's timeout SIGKILL
1094/// reaches the whole tree, sandbox-exec/bwrap/the runtime client and every
1095/// descendant alike. The container arm additionally force-removes its NAMED
1096/// container when a run produced no exit code ([`WrappedCommand::timeout_teardown`]):
1097/// the group SIGKILL stops the runtime client, but the in-container tree
1098/// belongs to the daemon and would otherwise outlive the killed client.
1099pub(crate) async fn run_shell_command_sandboxed(
1100    cwd: &std::path::Path,
1101    command: &str,
1102    env: &HashMap<String, String>,
1103    sandbox: &GateSandbox,
1104) -> (bool, String) {
1105    let (code, output) =
1106        run_shell_command_sandboxed_with_code(cwd, command, COMMAND_TIMEOUT, env, sandbox).await;
1107    (code == Some(0), output)
1108}
1109
1110/// [`run_shell_command_sandboxed`] with an explicit timeout and the real
1111/// exit code (the [`run_shell_command_with_code`] shape), so the merge-gate
1112/// runner and tests can drive the same path.
1113async fn run_shell_command_sandboxed_with_code(
1114    cwd: &std::path::Path,
1115    command: &str,
1116    timeout: Duration,
1117    env: &HashMap<String, String>,
1118    sandbox: &GateSandbox,
1119) -> (Option<i32>, String) {
1120    // The FINAL env first (the fs+net offline-by-cache adjustment included) —
1121    // the container arm bakes it into the argv as `-e` flags, so wrap_shell
1122    // must see the adjusted map, not the caller's original.
1123    let env = gate_env_for_sandbox(env, sandbox);
1124    let wrapped = match sandbox.wrap_shell(command, &env) {
1125        Ok(wrapped) => wrapped,
1126        Err(error) => {
1127            return (
1128                None,
1129                format!("gate sandbox wrap failed closed (the command did not run): {error}"),
1130            )
1131        }
1132    };
1133    // The host runtime needs its own context/connection settings. The payload
1134    // already received only the sanitized gate env as explicit container flags.
1135    let client_env = match sandbox {
1136        GateSandbox::Container { spec, .. } => spec.runtime.client_env(),
1137        _ => env,
1138    };
1139    let (code, output) =
1140        run_bounded_argv(cwd, &wrapped.program, &wrapped.args, timeout, &client_env).await;
1141    if code.is_none() {
1142        if let Some((program, args)) = wrapped.timeout_teardown {
1143            // Reuse the exact client context that started this container.
1144            let _ =
1145                run_bounded_argv(cwd, &program, &args, Duration::from_secs(30), &client_env).await;
1146        }
1147    }
1148    (code, output)
1149}
1150
1151/// Synchronous bounded runner for an already-resolved gate posture and its
1152/// complete cleared environment. Production validation/final-gate batches
1153/// resolve once and run several assertions through that same posture; the
1154/// native Windows normal-gate receipt uses this seam so its retained samples
1155/// measure per-command wrapping after the posture's one-time ACL preparation.
1156#[cfg(windows)]
1157pub(crate) fn run_bounded_gate_command_resolved_with_code(
1158    cwd: &std::path::Path,
1159    command: &str,
1160    env: &HashMap<String, String>,
1161    sandbox: &GateSandbox,
1162) -> (Option<i32>, String) {
1163    let runtime = match tokio::runtime::Builder::new_current_thread()
1164        .enable_all()
1165        .build()
1166    {
1167        Ok(runtime) => runtime,
1168        Err(error) => return (None, format!("failed to create gate runtime: {error}")),
1169    };
1170    runtime.block_on(run_shell_command_sandboxed_with_code(
1171        cwd,
1172        command,
1173        COMMAND_TIMEOUT,
1174        env,
1175        sandbox,
1176    ))
1177}
1178
1179/// Synchronous bridge for gate execution from approval-time code that runs
1180/// inside an ambient Tokio runtime. The actual bounded/sandboxed executor is
1181/// async; attempting to build and `block_on` a second runtime on the caller's
1182/// runtime thread panics. A scoped OS thread owns the short-lived runtime,
1183/// while borrowed cwd/env/sandbox inputs remain valid until it joins.
1184///
1185/// `Some(code)` means the command reached an exit status; `None` covers
1186/// spawn/wrap failures, timeout/tree kill, signal termination, or runtime
1187/// setup failure. The output always carries the bounded diagnostic tail.
1188pub(crate) fn run_shell_command_sandboxed_blocking(
1189    cwd: &std::path::Path,
1190    command: &str,
1191    timeout: Duration,
1192    env: &HashMap<String, String>,
1193    sandbox: &GateSandbox,
1194) -> (Option<i32>, String) {
1195    std::thread::scope(|scope| {
1196        let worker = scope.spawn(|| {
1197            let runtime = match tokio::runtime::Builder::new_current_thread()
1198                .enable_all()
1199                .build()
1200            {
1201                Ok(runtime) => runtime,
1202                Err(error) => {
1203                    return (
1204                        None,
1205                        format!("failed to create approval gate runtime: {error}"),
1206                    )
1207                }
1208            };
1209            runtime.block_on(run_shell_command_sandboxed_with_code(
1210                cwd, command, timeout, env, sandbox,
1211            ))
1212        });
1213        worker.join().unwrap_or_else(|_| {
1214            (
1215                None,
1216                "approval gate runner panicked before producing a verdict".to_string(),
1217            )
1218        })
1219    })
1220}
1221
1222/// Controls own their descendant group through every exit, including a shell
1223/// that exits after starting a child with redirected output. Ordinary gates
1224/// retain their existing execution semantics.
1225pub(crate) fn run_control_command_sandboxed_blocking(
1226    cwd: &std::path::Path,
1227    command: &str,
1228    timeout: Duration,
1229    env: &HashMap<String, String>,
1230    sandbox: &GateSandbox,
1231    cancelled: &std::sync::atomic::AtomicBool,
1232) -> (Option<i32>, String) {
1233    #[cfg(any(target_os = "macos", target_os = "linux"))]
1234    {
1235        std::thread::scope(|scope| {
1236            scope
1237                .spawn(|| {
1238                    let env = gate_env_for_sandbox(env, sandbox);
1239                    let wrapped = match sandbox.wrap_control_shell(cwd, command, &env) {
1240                        Ok(wrapped) => wrapped,
1241                        Err(error) => return (None, format!("control wrap failed: {error}")),
1242                    };
1243                    let runtime = match tokio::runtime::Builder::new_current_thread()
1244                        .enable_all()
1245                        .build()
1246                    {
1247                        Ok(runtime) => runtime,
1248                        Err(error) => return (None, format!("control runtime failed: {error}")),
1249                    };
1250                    let mut cmd = tokio::process::Command::new(&wrapped.program);
1251                    cmd.args(&wrapped.args).env_clear();
1252                    runtime.block_on(run_control_command_bounded(
1253                        configure_bounded_child(cmd, cwd, &env),
1254                        timeout,
1255                        cancelled,
1256                    ))
1257                })
1258                .join()
1259                .unwrap_or_else(|_| (None, "control runner panicked".into()))
1260        })
1261    }
1262    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1263    {
1264        let _ = (cwd, command, timeout, env, sandbox, cancelled);
1265        (
1266            None,
1267            "negative controls require native macOS/Linux containment".into(),
1268        )
1269    }
1270}
1271
1272#[cfg(any(target_os = "macos", target_os = "linux"))]
1273struct ControlChild(tokio::process::Child);
1274
1275#[cfg(any(target_os = "macos", target_os = "linux"))]
1276impl Drop for ControlChild {
1277    fn drop(&mut self) {
1278        // Child::id becomes None after wait/reap. Never signal a cached PID.
1279        crate::backend_claude::kill_unreaped_group(&self.0);
1280    }
1281}
1282
1283#[cfg(any(target_os = "macos", target_os = "linux"))]
1284async fn control_leader_exited(pid: u32) -> std::io::Result<()> {
1285    loop {
1286        let exited = {
1287            // SAFETY: zeroed siginfo_t is a valid output buffer. WNOWAIT
1288            // observes our owned child and retains its zombie/PID for group
1289            // kill. Keep siginfo_t's platform pointers out of async state.
1290            let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
1291            let result = unsafe {
1292                libc::waitid(
1293                    libc::P_PID,
1294                    pid as libc::id_t,
1295                    &mut info,
1296                    libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
1297                )
1298            };
1299            if result != 0 {
1300                let error = std::io::Error::last_os_error();
1301                if error.kind() != std::io::ErrorKind::Interrupted {
1302                    return Err(error);
1303                }
1304                false
1305            } else {
1306                unsafe { info.si_pid() != 0 }
1307            }
1308        };
1309        if exited {
1310            return Ok(());
1311        }
1312        tokio::time::sleep(Duration::from_millis(10)).await;
1313    }
1314}
1315
1316#[cfg(any(target_os = "macos", target_os = "linux"))]
1317async fn run_control_command_bounded(
1318    mut cmd: tokio::process::Command,
1319    timeout: Duration,
1320    cancelled: &std::sync::atomic::AtomicBool,
1321) -> (Option<i32>, String) {
1322    use std::sync::atomic::Ordering;
1323    if cancelled.load(Ordering::Acquire) {
1324        return (None, "control evaluation cancelled".into());
1325    }
1326    let mut child = match cmd.spawn() {
1327        Ok(child) => ControlChild(child),
1328        Err(error) => return (None, format!("failed to spawn control: {error}")),
1329    };
1330    let stdout = child.0.stdout.take().expect("stdout is piped");
1331    let stderr = child.0.stderr.take().expect("stderr is piped");
1332    let capture = async { tokio::try_join!(read_stream_tail(stdout), read_stream_tail(stderr)) };
1333    tokio::pin!(capture);
1334    let leader = control_leader_exited(child.0.id().expect("unreaped child has an id"));
1335    tokio::pin!(leader);
1336    let cancellation = async {
1337        while !cancelled.load(Ordering::Acquire) {
1338            tokio::time::sleep(Duration::from_millis(10)).await;
1339        }
1340    };
1341    tokio::pin!(cancellation);
1342    let mut output = None;
1343    let execution = async {
1344        loop {
1345            tokio::select! {
1346                result = &mut leader => return result.map_err(|error| format!("control wait failed: {error}")),
1347                () = &mut cancellation => return Err("control evaluation cancelled".into()),
1348                result = &mut capture, if output.is_none() => {
1349                    output = Some(result.map_err(|error| format!("control output failed: {error}"))?);
1350                }
1351            }
1352        }
1353    };
1354    let result = match tokio::time::timeout(timeout, execution).await {
1355        Ok(result) => result,
1356        Err(_) => Err(format!("timed out after {}s", timeout.as_secs())),
1357    };
1358    // No Child::wait/try_wait has run: even on normal exit its zombie pins the
1359    // process group identity until every same-group descendant is killed.
1360    crate::backend_claude::kill_unreaped_group(&child.0);
1361    if result.is_err() {
1362        // A live leader can leave its original group. Its still-owned PID
1363        // remains safe to target directly; do not wait indefinitely for it.
1364        let _ = child.0.start_kill();
1365    }
1366    let status = child.0.wait().await;
1367    if let Err(error) = result {
1368        return (None, error);
1369    }
1370    let status = match status {
1371        Ok(status) => status,
1372        Err(error) => return (None, format!("control reap failed: {error}")),
1373    };
1374    let (stdout, stderr) = match output {
1375        Some(output) => output,
1376        None => match tokio::time::timeout(Duration::from_secs(1), &mut capture).await {
1377            Ok(Ok(output)) => output,
1378            Ok(Err(error)) => return (None, format!("control output failed: {error}")),
1379            Err(_) => {
1380                return (
1381                    None,
1382                    "control output remained open after group cleanup".into(),
1383                )
1384            }
1385        },
1386    };
1387    let mut combined = stdout;
1388    if !stderr.trim().is_empty() {
1389        combined.push_str("\n--- stderr ---\n");
1390        combined.push_str(stderr.trim_end());
1391    }
1392    (
1393        status.code(),
1394        tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1395    )
1396}
1397
1398/// Child setup shared by every bounded run: piped stdout/stderr (drained
1399/// concurrently by [`run_command_bounded`]), stdin null, kill_on_drop, and —
1400/// on unix — the child as leader of a NEW process group, so the timeout path
1401/// can kill the entire command tree, not just the direct child.
1402fn configure_bounded_child(
1403    mut cmd: tokio::process::Command,
1404    cwd: &std::path::Path,
1405    env: &HashMap<String, String>,
1406) -> tokio::process::Command {
1407    cmd.current_dir(cwd)
1408        .envs(env)
1409        .stdin(std::process::Stdio::null())
1410        .stdout(std::process::Stdio::piped())
1411        .stderr(std::process::Stdio::piped())
1412        .kill_on_drop(true);
1413    #[cfg(unix)]
1414    cmd.process_group(0);
1415    cmd
1416}
1417
1418/// Bounded-execution core: spawn an already-configured command, drain both
1419/// pipes CONCURRENTLY with the wait (a full pipe never deadlocks the child),
1420/// keep only the tailed combined output, and on timeout kill the whole
1421/// process tree.
1422///
1423/// Timeout kill semantics: on unix the child leads its own process group
1424/// ([`configure_bounded_child`]) and the WHOLE group gets SIGKILL — killing
1425/// only the wrapper (kill_on_drop) would leave `sleep 300 &`-style
1426/// descendants running (and holding the output pipes) long after the gate
1427/// gave up. The killed wrapper itself is reaped by tokio's background orphan
1428/// reaper (kill_on_drop); group members are re-parented to init and reaped
1429/// there.
1430///
1431/// Windows has no process groups; the equivalent is a Job Object with
1432/// `KILL_ON_JOB_CLOSE` (see [`crate::backend_claude::win_job`]). The spawned
1433/// child is assigned to such a job right after spawn, so on timeout
1434/// `TerminateJobObject` takes the whole tree down — not just the wrapper.
1435/// That path compiles and is validated only on windows-latest CI, never on
1436/// the dev host.
1437async fn run_command_bounded(
1438    cmd: tokio::process::Command,
1439    timeout: Duration,
1440) -> (Option<i32>, String) {
1441    let mut cmd = cmd;
1442    let mut child = match cmd.spawn() {
1443        Ok(child) => child,
1444        Err(e) => return (None, format!("failed to spawn shell: {e}")),
1445    };
1446    let stdout = child.stdout.take().expect("stdout was configured as piped");
1447    let stderr = child.stderr.take().expect("stderr was configured as piped");
1448    #[cfg(unix)]
1449    let group_pid = child.id();
1450
1451    // Windows: assign the spawned child to a kill-on-close Job Object so the
1452    // timeout path can kill the whole command tree. Held across the await; on
1453    // timeout it is killed explicitly and, either way, dropped at scope end
1454    // (CloseHandle → KILL_ON_JOB_CLOSE). Job setup failure is non-fatal — the
1455    // command still runs, timeout just falls back to killing the child only.
1456    // Compiled and validated only on windows-latest CI.
1457    #[cfg(windows)]
1458    let job = match child.raw_handle() {
1459        Some(handle) => crate::backend_claude::win_job::JobHandle::create_and_assign(handle)
1460            .map_err(|e| {
1461                tracing::warn!(error = %e, "failed to create Job Object for shell command; \
1462                    timeout will kill only the spawned child");
1463            })
1464            .ok(),
1465        None => None,
1466    };
1467
1468    let execution = async {
1469        let (status, stdout, stderr) = tokio::join!(
1470            child.wait(),
1471            read_stream_tail(stdout),
1472            read_stream_tail(stderr)
1473        );
1474        Ok::<_, String>((
1475            status.map_err(|e| format!("failed waiting for shell: {e}"))?,
1476            stdout.map_err(|e| format!("failed reading shell stdout: {e}"))?,
1477            stderr.map_err(|e| format!("failed reading shell stderr: {e}"))?,
1478        ))
1479    };
1480    match tokio::time::timeout(timeout, execution).await {
1481        Err(_elapsed) => {
1482            // The read futures were dropped with `execution`; SIGKILL the
1483            // whole group so descendants die too (a still-live member keeps
1484            // the pgid valid, and the leader zombie pins it until reaped).
1485            #[cfg(unix)]
1486            if let Some(pid) = group_pid {
1487                // Negative pid targets every process in the group.
1488                unsafe {
1489                    libc::kill(-(pid as i32), libc::SIGKILL);
1490                }
1491            }
1492            // Windows: TerminateJobObject kills the whole tree now (dropping
1493            // `job` at scope end would also do it via KILL_ON_JOB_CLOSE, but
1494            // the explicit kill is deterministic).
1495            #[cfg(windows)]
1496            if let Some(job) = &job {
1497                job.kill();
1498            }
1499            let _ = child.kill().await;
1500            let _ = child.wait().await;
1501            (None, format!("timed out after {}s", timeout.as_secs()))
1502        }
1503        Ok(Err(error)) => (None, error),
1504        Ok(Ok((status, stdout, stderr))) => {
1505            let mut combined = stdout;
1506            if !stderr.trim().is_empty() {
1507                combined.push_str("\n--- stderr ---\n");
1508                combined.push_str(stderr.trim_end());
1509            }
1510            // `status.code()` is None on signal termination; the bool shape
1511            // (`success()`) is recovered by callers as `code == Some(0)`.
1512            (
1513                status.code(),
1514                tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1515            )
1516        }
1517    }
1518}
1519
1520async fn read_stream_tail<R>(mut reader: R) -> std::io::Result<String>
1521where
1522    R: AsyncRead + Unpin,
1523{
1524    let max_bytes = COMMAND_OUTPUT_TAIL * 4;
1525    let mut tail = Vec::with_capacity(max_bytes);
1526    let mut chunk = [0u8; 8192];
1527    loop {
1528        let read = reader.read(&mut chunk).await?;
1529        if read == 0 {
1530            break;
1531        }
1532        if read >= max_bytes {
1533            tail.clear();
1534            tail.extend_from_slice(&chunk[read - max_bytes..read]);
1535            continue;
1536        }
1537        let excess = tail.len().saturating_add(read).saturating_sub(max_bytes);
1538        if excess > 0 {
1539            tail.drain(..excess);
1540        }
1541        tail.extend_from_slice(&chunk[..read]);
1542    }
1543    Ok(tail_chars(
1544        &String::from_utf8_lossy(&tail),
1545        COMMAND_OUTPUT_TAIL,
1546    ))
1547}
1548
1549/// Execute one repository-owned merge gate with the same process-tree timeout
1550/// used by validation-contract commands, but with a deliberately small
1551/// inherited environment. This synchronous wrapper is intended for a
1552/// `spawn_blocking` thread; it owns a current-thread runtime so the robust
1553/// async timeout/kill implementation remains the single source of truth.
1554///
1555/// The gate env intentionally retains ambient `HOME`/`CI`/temp dirs (the
1556/// operator's toolchain shape — see `agent_env`'s module doc), but NOT the
1557/// ambient `CARGO_HOME`: gate commands execute worker-authored build scripts
1558/// and test binaries engine-side, and the real Cargo root carries registry
1559/// credentials and credential-provider config.
1560/// It is replaced with a fresh cache-only home (registry/git seeded as
1561/// per-env copies — clonefile/reflink/plain — never credentials;
1562/// [`crate::agent_env::cache_only_cargo_home`]) over a temp scratch that
1563/// self-cleans when the gate returns. The
1564/// substitution FAILS CLOSED: no scratch, no gate run — running with the
1565/// ambient Cargo root is the hole this exists to close.
1566///
1567/// This is the UNSANDBOXED executor — today's exact behavior, kept for the
1568/// `enforce == off` posture. When the merged mission's
1569/// `worker.sandbox.enforce` is not `off`, the server routes to
1570/// [`run_bounded_gate_command_sandboxed`] instead.
1571pub fn run_bounded_gate_command(cwd: &std::path::Path, command: &str) -> (bool, String) {
1572    // cache_only_cargo_home creates a fresh unpredictable dir under the
1573    // given base; the system temp dir keeps it out of the gated worktree
1574    // (an untracked `.cargo-cache-only-*` at the root would dirty every
1575    // gate's `git status`). The dir holds the seeded registry/git cache
1576    // copies plus whatever Cargo drops at its root; it is removed after the
1577    // run.
1578    let cargo_home = crate::agent_env::cache_only_cargo_home(std::env::temp_dir().as_path());
1579    if !cargo_home.is_dir() {
1580        return (
1581            false,
1582            format!(
1583                "could not create the gate's cache-only Cargo home at {}",
1584                cargo_home.display()
1585            ),
1586        );
1587    }
1588    let mut env = sanitized_gate_env();
1589    env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1590    let runtime = match tokio::runtime::Builder::new_current_thread()
1591        .enable_all()
1592        .build()
1593    {
1594        Ok(runtime) => runtime,
1595        Err(error) => return (false, format!("failed to create gate runtime: {error}")),
1596    };
1597    let (code, output) = runtime.block_on(run_shell_command_with_timeout_env(
1598        cwd,
1599        command,
1600        COMMAND_TIMEOUT,
1601        &env,
1602        true,
1603    ));
1604    let _ = std::fs::remove_dir_all(&cargo_home);
1605    (code == Some(0), output)
1606}
1607
1608/// What the merge-gate path needs to wrap its gates (ticket
1609/// engine-gates-sandbox-wrapped): the MERGED mission's `worker.sandbox`
1610/// config (the gates execute that mission's worker-authored test/build code,
1611/// so the worker role's posture is the right one — the same choice the
1612/// sandbox preflight makes for contract-command probes) and the mission dir
1613/// the metadata write-denies / authority read-denies derive from. The server
1614/// builds one per merge from the folded event state; the gate cwd is only
1615/// known per command, so the profile resolution itself happens per command
1616/// inside [`run_bounded_gate_command_sandboxed`].
1617pub struct MergeGatePolicy {
1618    pub sandbox: crate::types::SandboxConfig,
1619    pub mission_dir: std::path::PathBuf,
1620}
1621
1622impl MergeGatePolicy {
1623    /// The no-enforcement policy: gates run exactly as before the wrap.
1624    pub fn disabled() -> Self {
1625        MergeGatePolicy {
1626            sandbox: crate::types::SandboxConfig::default(),
1627            mission_dir: std::path::PathBuf::new(),
1628        }
1629    }
1630
1631    /// Whether resolution on THIS host yields an enforced wrap — the cheap
1632    /// pre-check callers use to choose between the sandboxed runner and their
1633    /// pre-existing executor seam. `false` only for `enforce: off` (the
1634    /// byte-identical pre-wrap path). Every requested enforcement returns
1635    /// `true` — the process provider on any platform (`platform_support`
1636    /// decides the wrap shape), the container provider with or without a
1637    /// detected runtime (ticket container-gate-wrapper: a runtime wraps the
1638    /// gate in the mission container; none FAILS CLOSED at resolve), and the
1639    /// fail-closed postures (a platform
1640    /// [`crate::sandbox::platform_support`] cannot honor, linux WITHOUT
1641    /// `bwrap`) — those route INTO the sandboxed runner so they error loudly
1642    /// at resolve rather than running unsandboxed (13th-pass review, P1).
1643    pub fn enforces_on_this_host(&self) -> bool {
1644        if self.sandbox.enforce == crate::types::SandboxEnforce::Off {
1645            return false;
1646        }
1647        match self.sandbox.provider {
1648            crate::types::SandboxProvider::Process => !matches!(
1649                crate::sandbox::platform_support(self.sandbox.enforce, std::env::consts::OS),
1650                crate::sandbox::SandboxDecision::Off
1651            ),
1652            crate::types::SandboxProvider::Container => true,
1653        }
1654    }
1655
1656    /// The operator-visible note when this policy CANNOT wrap gates despite
1657    /// `enforce != off`: `provider: container` with no container runtime on
1658    /// PATH (ticket container-gate-wrapper). The merge gates themselves then
1659    /// FAIL CLOSED at resolve — the server's merge path has no event log and
1660    /// MUST log this note so the refusal reads as the operator's config
1661    /// problem it is, not a flaky gate. `None` for `enforce: off` (nothing
1662    /// to refuse), for the process provider (which wraps, or fails closed
1663    /// loudly at resolve — an unsupported platform or linux without `bwrap`
1664    /// needs no note because it errors), and for a container policy WITH a
1665    /// runtime (the gates wrap in the mission container — nothing degraded).
1666    pub fn degradation_note(&self) -> Option<String> {
1667        self.degradation_note_target(crate::sandbox_container::detect())
1668    }
1669
1670    /// [`MergeGatePolicy::degradation_note`] parameterized on runtime
1671    /// detection so the decision is testable without a container runtime
1672    /// (mirrors [`resolve_gate_sandbox_target`]).
1673    pub(crate) fn degradation_note_target(
1674        &self,
1675        container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
1676    ) -> Option<String> {
1677        if self.sandbox.provider == crate::types::SandboxProvider::Container
1678            && self.sandbox.enforce != crate::types::SandboxEnforce::Off
1679            && container_runtime.is_none()
1680        {
1681            Some(container_gate_note(self.sandbox.enforce))
1682        } else {
1683            None
1684        }
1685    }
1686}
1687
1688/// [`run_bounded_gate_command`] under a [`MergeGatePolicy`] (ticket
1689/// engine-gates-sandbox-wrapped). A non-enforcing policy delegates to
1690/// [`run_bounded_gate_command`] unchanged — the byte-identical off path. An
1691/// enforcing policy runs the gate inside the resolved profile with:
1692///
1693/// - the SAME sanitized env, ambient `HOME` included — under the profile the
1694///   real home is simply outside the writable roots, i.e. a READ-ONLY home:
1695///   `~/.gitconfig` identity reads keep working (probed under Seatbelt; see
1696///   `gate_sandbox_wrap_merge_gate_reads_git_identity_from_read_only_home`),
1697///   while writes to `$HOME` are denied. That replaces the ticket's
1698///   open question — no HOME redirect is needed, so the pass-through stays
1699///   and the profile does the containment;
1700/// - `TMPDIR`/`TMP`/`TEMP` redirected into a fresh per-run scratch
1701///   (`kranz-gate-<uuid>/tmp`): the ambient temp dir is deliberately NOT in
1702///   the writable roots (sandbox-writable-scope parity — the shared temp
1703///   root holds every sibling mission's worktrees), and a gate that cannot
1704///   write temp files fails in opaque ways;
1705/// - the cache-only Cargo home created INSIDE that scratch (the unsandboxed
1706///   path places it directly under the system temp root, which the profile
1707///   denies);
1708/// - the whole scratch — Seatbelt profile file included — removed after the
1709///   run, and every setup failure failing CLOSED (no scratch, no profile, no
1710///   gate run — never a silent unsandboxed fallback under enforcement).
1711pub fn run_bounded_gate_command_sandboxed(
1712    cwd: &std::path::Path,
1713    command: &str,
1714    policy: &MergeGatePolicy,
1715) -> (bool, String) {
1716    let (code, output) = run_bounded_gate_command_sandboxed_with_code(cwd, command, policy);
1717    (code == Some(0), output)
1718}
1719
1720/// The production sandboxed merge-gate runner with its exact child exit
1721/// status retained. Normal callers need only the stable bool/output API
1722/// above; the Windows production receipt keeps the status so a native CI
1723/// failure can distinguish a missing output marker from a process failure.
1724pub(crate) fn run_bounded_gate_command_sandboxed_with_code(
1725    cwd: &std::path::Path,
1726    command: &str,
1727    policy: &MergeGatePolicy,
1728) -> (Option<i32>, String) {
1729    if !policy.enforces_on_this_host() {
1730        let (ok, output) = run_bounded_gate_command(cwd, command);
1731        return (Some(i32::from(!ok)), output);
1732    }
1733    let scratch =
1734        std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
1735    if std::fs::create_dir_all(scratch.join("tmp")).is_err() {
1736        return (
1737            None,
1738            format!(
1739                "could not create the gate's sandbox scratch at {}",
1740                scratch.display()
1741            ),
1742        );
1743    }
1744    let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
1745    if !cargo_home.is_dir() {
1746        let _ = std::fs::remove_dir_all(&scratch);
1747        return (
1748            None,
1749            format!(
1750                "could not create the gate's cache-only Cargo home at {}",
1751                cargo_home.display()
1752            ),
1753        );
1754    }
1755    let runtime = match tokio::runtime::Builder::new_current_thread()
1756        .enable_all()
1757        .build()
1758    {
1759        Ok(runtime) => runtime,
1760        Err(error) => {
1761            let _ = std::fs::remove_dir_all(&scratch);
1762            return (None, format!("failed to create gate runtime: {error}"));
1763        }
1764    };
1765    let mut resolution = match resolve_gate_sandbox(
1766        &policy.sandbox,
1767        cwd,
1768        &policy.mission_dir,
1769        &scratch,
1770        &scratch,
1771    ) {
1772        Ok(resolution) => resolution,
1773        Err(error) => {
1774            let _ = std::fs::remove_dir_all(&scratch);
1775            return (
1776                None,
1777                format!("could not resolve the gate sandbox (failing closed): {error}"),
1778            );
1779        }
1780    };
1781    if let Some(note) = &resolution.note {
1782        // Unreachable today (enforces_on_this_host excludes every noted
1783        // posture); kept so a future posture can never degrade silently.
1784        tracing::warn!(note = %note, "merge gate sandbox degraded to a no-op");
1785    }
1786    let mut env = sanitized_gate_env();
1787    env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1788    #[cfg(windows)]
1789    crate::agent_env::redirect_windows_profile_env(&mut env, &scratch);
1790    #[cfg(not(windows))]
1791    for var in ["TMPDIR", "TMP", "TEMP"] {
1792        env.insert(var.to_string(), scratch.join("tmp").display().to_string());
1793    }
1794    let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
1795        cwd,
1796        command,
1797        COMMAND_TIMEOUT,
1798        &env,
1799        &resolution.sandbox,
1800    ));
1801    if let Err(error) = resolution.sandbox.cleanup() {
1802        let _ = std::fs::remove_dir_all(&scratch);
1803        return (
1804            None,
1805            format!("gate sandbox cleanup failed closed after command execution: {error}"),
1806        );
1807    }
1808    let _ = std::fs::remove_dir_all(&scratch);
1809    (code, output)
1810}
1811
1812pub(crate) fn sanitized_gate_env() -> HashMap<String, String> {
1813    // Keep only process/toolchain location and locale values. In particular,
1814    // API keys, GitHub/Slack tokens, cloud credentials, SSH agent sockets and
1815    // arbitrary server configuration never cross into mission-authored tests.
1816    // `CARGO_HOME` is deliberately ABSENT from this list — the caller
1817    // substitutes a cache-only home (see `run_bounded_gate_command`); the
1818    // ambient Cargo root is a credential directory.
1819    const SAFE: &[&str] = &[
1820        "PATH",
1821        "HOME",
1822        "USERPROFILE",
1823        "TMPDIR",
1824        "TMP",
1825        "TEMP",
1826        "RUSTUP_HOME",
1827        "NPM_CONFIG_CACHE",
1828        "CI",
1829        "TERM",
1830        "LANG",
1831        "LC_ALL",
1832        "TZ",
1833    ];
1834    let env: HashMap<String, String> = SAFE
1835        .iter()
1836        .filter_map(|key| {
1837            std::env::var_os(key).map(|value| ((*key).to_string(), value.to_string_lossy().into()))
1838        })
1839        .collect();
1840    #[cfg(windows)]
1841    let env = {
1842        let mut env = env;
1843        crate::agent_env::extend_windows_process_env(&mut env);
1844        // USERPROFILE is redirected to gate scratch before the child starts.
1845        // Resolve the operator's rustup home now so standard installations
1846        // that leave RUSTUP_HOME unset still find their toolchain. CARGO_HOME
1847        // remains absent here and is replaced with the cache-only root by the
1848        // gate runners.
1849        crate::agent_env::extend_noncredential_toolchain_env(&mut env);
1850        env
1851    };
1852    env
1853}
1854
1855/// Last `max` characters of `text` (char-safe).
1856pub(crate) fn tail_chars(text: &str, max: usize) -> String {
1857    let count = text.chars().count();
1858    if count <= max {
1859        return text.to_string();
1860    }
1861    text.chars().skip(count - max).collect()
1862}
1863
1864// ---------------------------------------------------------------------------
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869    #[cfg(unix)]
1870    use crate::runner;
1871
1872    #[test]
1873    fn control_wrapper_keeps_scratch_mounts_and_positional_snapshot_cwd() {
1874        let root = tempfile::tempdir().unwrap();
1875        let scratch = root.path().join("scratch");
1876        let snapshot = root.path().join("readonly snapshot's checkout");
1877        std::fs::create_dir(&scratch).unwrap();
1878        std::fs::create_dir(&snapshot).unwrap();
1879        let inputs = gate_sandbox_inputs(
1880            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
1881            &scratch,
1882            &root.path().join(".kranz/missions/control"),
1883            &scratch,
1884        );
1885        let sandbox = GateSandbox::Bubblewrap {
1886            inputs: Box::new(inputs),
1887        };
1888        let command = "sh check.sh && printf '%s' \"$HOME\"";
1889        let wrapped = sandbox
1890            .wrap_control_shell(&snapshot, command, &HashMap::new())
1891            .unwrap();
1892        let chdir = wrapped
1893            .args
1894            .iter()
1895            .position(|arg| arg == "--chdir")
1896            .unwrap();
1897        assert_eq!(
1898            wrapped.args[chdir + 1],
1899            std::fs::canonicalize(&scratch)
1900                .unwrap()
1901                .display()
1902                .to_string()
1903        );
1904        assert_eq!(
1905            &wrapped.args[chdir + 2..],
1906            &[
1907                "--",
1908                "/bin/sh",
1909                "-c",
1910                "cd -- \"$1\" && exec /bin/sh -c \"$2\"",
1911                "kranz-control",
1912                &snapshot.display().to_string(),
1913                command,
1914            ]
1915        );
1916        let writes: Vec<_> = wrapped
1917            .args
1918            .windows(3)
1919            .filter(|args| args[0] == "--bind")
1920            .map(|args| args[2].clone())
1921            .collect();
1922        assert!(writes.contains(
1923            &std::fs::canonicalize(&scratch)
1924                .unwrap()
1925                .display()
1926                .to_string()
1927        ));
1928        assert!(!writes.contains(&snapshot.display().to_string()));
1929        assert!(GateSandbox::Disabled
1930            .wrap_control_shell(&snapshot, command, &HashMap::new())
1931            .is_err());
1932    }
1933
1934    #[cfg(any(target_os = "macos", target_os = "linux"))]
1935    #[tokio::test]
1936    async fn control_wait_retains_the_leader_until_group_cleanup() {
1937        let root = tempfile::tempdir().unwrap();
1938        let mut command = tokio::process::Command::new("/bin/sh");
1939        command.args(["-c", "exit 7"]).env_clear();
1940        let mut child = ControlChild(
1941            configure_bounded_child(command, root.path(), &HashMap::new())
1942                .spawn()
1943                .unwrap(),
1944        );
1945        let pid = child.0.id().unwrap();
1946        for _ in 0..2 {
1947            tokio::time::timeout(Duration::from_secs(3), control_leader_exited(pid))
1948                .await
1949                .unwrap()
1950                .unwrap();
1951        }
1952        crate::backend_claude::kill_unreaped_group(&child.0);
1953        assert_eq!(child.0.wait().await.unwrap().code(), Some(7));
1954        assert!(
1955            child.0.id().is_none(),
1956            "the drop guard cannot signal a reaped PID"
1957        );
1958    }
1959
1960    #[cfg(any(target_os = "macos", target_os = "linux"))]
1961    #[tokio::test]
1962    async fn control_timeout_kills_a_leader_outside_its_original_group() {
1963        let root = tempfile::tempdir().unwrap();
1964        let ready = root.path().join("escaped-leader");
1965        let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
1966        command
1967            .args([
1968                "--ignored",
1969                "--exact",
1970                "command_exec::tests::control_escaped_leader_fixture",
1971                "--nocapture",
1972            ])
1973            .env_clear();
1974        let command = configure_bounded_child(
1975            command,
1976            root.path(),
1977            &HashMap::from([(
1978                "KRANZ_CONTROL_ESCAPED_LEADER".into(),
1979                ready.display().to_string(),
1980            )]),
1981        );
1982        let (code, output) = tokio::time::timeout(
1983            Duration::from_secs(5),
1984            run_control_command_bounded(
1985                command,
1986                Duration::from_secs(1),
1987                &std::sync::atomic::AtomicBool::new(false),
1988            ),
1989        )
1990        .await
1991        .expect("cleanup must terminate the escaped direct child before waiting");
1992        let evidence =
1993            std::fs::read_to_string(ready).expect("fixture moved out of its original group");
1994        let (pid, group) = evidence.split_once(' ').unwrap();
1995        assert_ne!(pid, group, "fixture must leave its original group");
1996        assert_eq!(code, None, "{output}");
1997        assert!(output.contains("timed out"), "{output}");
1998    }
1999
2000    #[cfg(any(target_os = "macos", target_os = "linux"))]
2001    #[test]
2002    #[ignore = "disposable subprocess fixture for direct-child timeout cleanup"]
2003    fn control_escaped_leader_fixture() {
2004        let Some(ready) = std::env::var_os("KRANZ_CONTROL_ESCAPED_LEADER") else {
2005            return;
2006        };
2007        // Only this disposable child changes group. The supervisor must target
2008        // its original group and owned PID, never signal the parent's group.
2009        let group = unsafe { libc::getpgid(libc::getppid()) };
2010        assert!(group > 0);
2011        assert_eq!(unsafe { libc::setpgid(0, group) }, 0);
2012        std::fs::write(ready, format!("{} {group}", std::process::id())).unwrap();
2013        std::thread::sleep(Duration::from_secs(30));
2014    }
2015
2016    #[cfg(any(target_os = "macos", target_os = "linux"))]
2017    #[tokio::test]
2018    async fn control_abort_cleans_unreaped_descendants() {
2019        let root = tempfile::tempdir().unwrap();
2020        let ready = root.path().join("ready");
2021        let marker = root.path().join("survived");
2022        let mut command = tokio::process::Command::new("/bin/sh");
2023        command
2024            .args([
2025                "-c",
2026                "(sleep 1; printf survived > \"$MARKER\") >/dev/null 2>&1 & printf ready > \"$READY\"; wait",
2027            ])
2028            .env_clear();
2029        let command = configure_bounded_child(
2030            command,
2031            root.path(),
2032            &HashMap::from([
2033                ("PATH".into(), "/usr/bin:/bin".into()),
2034                ("READY".into(), ready.display().to_string()),
2035                ("MARKER".into(), marker.display().to_string()),
2036            ]),
2037        );
2038        let task = tokio::spawn(async move {
2039            run_control_command_bounded(
2040                command,
2041                Duration::from_secs(5),
2042                &std::sync::atomic::AtomicBool::new(false),
2043            )
2044            .await
2045        });
2046        tokio::time::timeout(Duration::from_secs(3), async {
2047            while !ready.exists() {
2048                tokio::time::sleep(Duration::from_millis(10)).await;
2049            }
2050        })
2051        .await
2052        .expect("checker started before cancellation");
2053        task.abort();
2054        assert!(task.await.unwrap_err().is_cancelled());
2055        tokio::time::sleep(Duration::from_millis(1200)).await;
2056        assert!(!marker.exists(), "aborted runner left a live descendant");
2057    }
2058
2059    #[cfg(any(target_os = "macos", target_os = "linux"))]
2060    #[test]
2061    fn control_wrapper_reads_snapshot_and_cleans_every_exit() {
2062        use std::sync::atomic::{AtomicBool, Ordering};
2063        let _lock = GATE_SANDBOX_WRAP_LOCK
2064            .lock()
2065            .unwrap_or_else(|error| error.into_inner());
2066        if !gate_wrap_enforcement_available() {
2067            return;
2068        }
2069        let _env = crate::agent_env::EnvTestGuard::engage(&[(
2070            "KRANZ_CONTROL_AMBIENT_SENTINEL",
2071            "not-authorized",
2072        )]);
2073        let (repo, mission) = gate_wrap_layout();
2074        let snapshot = repo.path().join("readonly snapshot's checkout");
2075        std::fs::create_dir(&snapshot).unwrap();
2076        std::fs::write(snapshot.join("checker-input"), "approved").unwrap();
2077        let checker = r#"set -eu
2078[ "$(cat checker-input)" = approved ]
2079[ -z "${KRANZ_CONTROL_AMBIENT_SENTINEL+x}" ]
2080[ "$CARGO_NET_OFFLINE" = true ]
2081if (printf changed > checker-input) 2>/dev/null; then exit 90; fi
2082if [ "$MODE" = inherited ]; then
2083  (sleep 2; printf survived > "$CONTROL_MARKER") &
2084else
2085  (sleep 2; printf survived > "$CONTROL_MARKER") >/dev/null 2>&1 &
2086fi
2087printf ready > "$CONTROL_READY"
2088printf control-stdout
2089printf control-stderr >&2
2090case "$MODE" in
2091  nonzero) exit 7;;
2092  timeout|cancel) wait;;
2093esac
2094"#;
2095        std::fs::write(snapshot.join("check.sh"), checker).unwrap();
2096        let scratch = tempfile::tempdir().unwrap();
2097        let sandbox = resolve_gate_sandbox(
2098            &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
2099            scratch.path(),
2100            &mission,
2101            scratch.path(),
2102            scratch.path(),
2103        )
2104        .unwrap()
2105        .sandbox;
2106        let mut markers = Vec::new();
2107        for mode in ["success", "nonzero", "inherited", "timeout", "cancel"] {
2108            let marker = scratch.path().join(format!("{mode}.survived"));
2109            let ready = scratch.path().join(format!("{mode}.ready"));
2110            let env = HashMap::from([
2111                ("PATH".into(), "/usr/bin:/bin".into()),
2112                ("MODE".into(), mode.into()),
2113                ("CONTROL_MARKER".into(), marker.display().to_string()),
2114                ("CONTROL_READY".into(), ready.display().to_string()),
2115            ]);
2116            let cancelled = AtomicBool::new(false);
2117            let (code, output) = std::thread::scope(|scope| {
2118                let ready = &ready;
2119                let cancelled = &cancelled;
2120                if mode == "cancel" {
2121                    scope.spawn(move || {
2122                        let deadline = std::time::Instant::now() + Duration::from_secs(5);
2123                        while !ready.exists() {
2124                            assert!(
2125                                std::time::Instant::now() < deadline,
2126                                "checker did not start"
2127                            );
2128                            std::thread::sleep(Duration::from_millis(10));
2129                        }
2130                        cancelled.store(true, Ordering::Release);
2131                    });
2132                }
2133                run_control_command_sandboxed_blocking(
2134                    &snapshot,
2135                    "sh check.sh",
2136                    Duration::from_secs(if mode == "timeout" { 1 } else { 5 }),
2137                    &env,
2138                    &sandbox,
2139                    cancelled,
2140                )
2141            });
2142            assert!(ready.exists(), "{mode}: checker did not run: {output}");
2143            match mode {
2144                "timeout" => {
2145                    assert_eq!(code, None);
2146                    assert!(output.contains("timed out"));
2147                }
2148                "cancel" => {
2149                    assert_eq!(code, None);
2150                    assert!(output.contains("cancelled"));
2151                }
2152                _ => {
2153                    assert_eq!(
2154                        code,
2155                        Some(if mode == "nonzero" { 7 } else { 0 }),
2156                        "{output}"
2157                    );
2158                    assert!(output.contains("control-stdout"), "{output}");
2159                    assert!(output.contains("control-stderr"), "{output}");
2160                }
2161            }
2162            markers.push(marker);
2163        }
2164        // Prove the delayed marker works without supervision; all supervised
2165        // same-group children must be gone even when they closed both pipes.
2166        let control = scratch.path().join("unsupervised.survived");
2167        let mut positive = std::process::Command::new("/bin/sh")
2168            .args([
2169                "-c",
2170                "sleep 2; printf survived > \"$1\"",
2171                "positive",
2172                &control.display().to_string(),
2173            ])
2174            .spawn()
2175            .unwrap();
2176        assert!(positive.wait().unwrap().success());
2177        assert!(control.exists());
2178        for marker in markers {
2179            assert!(
2180                !marker.exists(),
2181                "descendant survived cleanup: {}",
2182                marker.display()
2183            );
2184        }
2185        assert_eq!(
2186            std::fs::read_to_string(snapshot.join("checker-input")).unwrap(),
2187            "approved"
2188        );
2189    }
2190
2191    #[test]
2192    fn tail_chars_keeps_the_end() {
2193        assert_eq!(tail_chars("abcdef", 3), "def");
2194        assert_eq!(tail_chars("ab", 3), "ab");
2195        assert_eq!(tail_chars("héllo", 2), "lo");
2196    }
2197
2198    /// Timeout kill discipline: the whole process GROUP dies, not just the
2199    /// `sh -c` wrapper — a backgrounded child must not survive the gate
2200    /// giving up. Unix-only test (`kill(-pgid)`); the Windows equivalent uses
2201    /// a kill-on-close Job Object (see `run_shell_command_with_timeout`) and
2202    /// is validated by windows-latest CI, not on this host.
2203    #[cfg(unix)]
2204    #[tokio::test]
2205    async fn shell_command_timeout_kills_the_whole_process_tree() {
2206        let dir = tempfile::tempdir().unwrap();
2207        let pidfile = dir.path().join("child.pid");
2208        // A background child that would outlive the wrapper by minutes; its
2209        // pid is written out before the shell parks in `wait`.
2210        let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2211
2212        let (ok, output) = tokio::time::timeout(
2213            Duration::from_secs(10),
2214            run_shell_command_with_timeout(
2215                dir.path(),
2216                &command,
2217                Duration::from_millis(500),
2218                &std::collections::HashMap::new(),
2219            ),
2220        )
2221        .await
2222        .expect("timed-out command must return promptly");
2223        assert!(!ok, "command must be reported failed: {output}");
2224        assert!(output.contains("timed out"), "got: {output}");
2225
2226        let pid: i32 = std::fs::read_to_string(&pidfile)
2227            .expect("shell wrote the background pid before the timeout")
2228            .trim()
2229            .parse()
2230            .expect("pidfile contains a pid");
2231
2232        // The group SIGKILL must take the background child down: poll until
2233        // kill(pid, 0) no longer reports it (dead + reaped by init), bounded.
2234        let deadline = std::time::Instant::now() + Duration::from_secs(5);
2235        while unsafe { libc::kill(pid, 0) } == 0 {
2236            assert!(
2237                std::time::Instant::now() < deadline,
2238                "background child {pid} survived the group kill"
2239            );
2240            tokio::time::sleep(Duration::from_millis(50)).await;
2241        }
2242    }
2243
2244    #[cfg(unix)]
2245    #[tokio::test]
2246    async fn shell_command_drains_large_output_while_running_and_keeps_only_the_tail() {
2247        let dir = tempfile::tempdir().unwrap();
2248        let command = "i=0; while [ \"$i\" -lt 20000 ]; do \
2249                       printf '0123456789abcdef0123456789abcdef\\n'; \
2250                       i=$((i + 1)); done; printf 'OUTPUT-END'";
2251
2252        let (ok, output) = run_shell_command_with_timeout(
2253            dir.path(),
2254            command,
2255            Duration::from_secs(10),
2256            &std::collections::HashMap::new(),
2257        )
2258        .await;
2259
2260        assert!(ok, "large-output command must complete: {output}");
2261        assert!(output.ends_with("OUTPUT-END"), "{output}");
2262        assert!(
2263            output.chars().count() <= COMMAND_OUTPUT_TAIL,
2264            "retained output exceeded the cap: {} chars",
2265            output.chars().count()
2266        );
2267    }
2268
2269    #[test]
2270    fn merge_gate_environment_excludes_server_secrets() {
2271        let env = sanitized_gate_env();
2272        for secret in [
2273            "ANTHROPIC_API_KEY",
2274            "OPENAI_API_KEY",
2275            "SLACK_BOT_TOKEN",
2276            "GITHUB_TOKEN",
2277            "GH_TOKEN",
2278            "SSH_AUTH_SOCK",
2279            "AWS_SECRET_ACCESS_KEY",
2280        ] {
2281            assert!(!env.contains_key(secret), "gate env leaked {secret}");
2282        }
2283        assert!(
2284            !env.contains_key("CARGO_HOME"),
2285            "the ambient Cargo root is a credential directory; \
2286             run_bounded_gate_command substitutes a cache-only home"
2287        );
2288        assert!(env.keys().all(|key| matches!(
2289            key.as_str(),
2290            "PATH"
2291                | "HOME"
2292                | "USERPROFILE"
2293                | "TMPDIR"
2294                | "TMP"
2295                | "TEMP"
2296                | "APPDATA"
2297                | "LOCALAPPDATA"
2298                | "SystemRoot"
2299                | "ComSpec"
2300                | "PATHEXT"
2301                | "SystemDrive"
2302                | "windir"
2303                | "OS"
2304                | "PROCESSOR_ARCHITECTURE"
2305                | "PSModulePath"
2306                | "RUSTUP_HOME"
2307                | "NPM_CONFIG_CACHE"
2308                | "CI"
2309                | "TERM"
2310                | "LANG"
2311                | "LC_ALL"
2312                | "TZ"
2313        )));
2314    }
2315
2316    /// contract-cargo-home-cache-only: the merge gate's `CARGO_HOME` is a
2317    /// fresh cache-only home — registry/git caches seeded, NO credentials —
2318    /// never the ambient Cargo root. Gate commands run worker-authored test
2319    /// code engine-side and unsandboxed, so this is the link that keeps
2320    /// registry tokens out of mission-authored code.
2321    #[cfg(unix)]
2322    #[test]
2323    fn contract_cargo_home_replaces_ambient_root_in_merge_gates() {
2324        let source = tempfile::tempdir().unwrap();
2325        std::fs::create_dir_all(source.path().join("registry")).unwrap();
2326        std::fs::write(source.path().join("registry/cache-marker"), "registry").unwrap();
2327        std::fs::write(source.path().join("credentials.toml"), "operator-secret").unwrap();
2328        let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2329            "CARGO_HOME",
2330            source.path().to_str().expect("utf-8 temp path"),
2331        )]);
2332        let dir = tempfile::tempdir().unwrap();
2333
2334        let (ok, output) = run_bounded_gate_command(
2335            dir.path(),
2336            "printf '%s' \"$CARGO_HOME\" \
2337             && test -f \"$CARGO_HOME/registry/cache-marker\" \
2338             && test ! -e \"$CARGO_HOME/credentials.toml\"",
2339        );
2340        assert!(
2341            ok,
2342            "gate command must see a seeded, credential-free Cargo home: {output}"
2343        );
2344        assert!(
2345            !output.is_empty() && output != source.path().to_string_lossy().as_ref(),
2346            "the gate must NOT receive the ambient Cargo root: {output}"
2347        );
2348    }
2349    /// agent-env-clear: a contract command run through the final-gate path
2350    /// (`run_shell_command`, env built by `contract_command_env`) cannot see
2351    /// poisoned ambient secrets — but does see PATH, the per-mission scratch
2352    /// HOME, KRANZ_BASE_SHA, the real rustup toolchain, and an isolated
2353    /// cache-only Cargo home.
2354    #[cfg(unix)]
2355    #[tokio::test]
2356    async fn contract_command_cannot_see_ambient_secrets() {
2357        let _poison = crate::agent_env::EnvTestGuard::engage(&[
2358            ("GH_TOKEN", "hunter2"),
2359            ("SLACK_BOT_TOKEN", "x"),
2360            ("AWS_SECRET_ACCESS_KEY", "y"),
2361        ]);
2362        let dir = tempfile::tempdir().unwrap();
2363        let scratch = tempfile::tempdir().unwrap();
2364        let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
2365
2366        // Probed by name: the poisoned vars are really unset in the child.
2367        let (ok, output) = run_shell_command(
2368            dir.path(),
2369            "test -z \"$GH_TOKEN\" && test -z \"$SLACK_BOT_TOKEN\" && test -z \"$AWS_SECRET_ACCESS_KEY\"",
2370            &env,
2371        )
2372        .await;
2373        assert!(
2374            ok,
2375            "poisoned ambient vars reached the contract command: {output}"
2376        );
2377
2378        // Inspect names separately from values. `run_shell_command` retains a
2379        // bounded output tail, and launcher-managed PATH values can themselves
2380        // exceed that bound; a raw `env` dump could therefore discard the
2381        // leading `PATH=` and make this boundary test host-PATH-dependent.
2382        let (ok, names) =
2383            run_shell_command(dir.path(), "env | sed 's/=.*//' | LC_ALL=C sort", &env).await;
2384        assert!(ok, "{names}");
2385        for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
2386            assert!(
2387                !names.lines().any(|name| name == leaked),
2388                "contract env leaked {leaked}:\n{names}"
2389            );
2390        }
2391        assert!(
2392            names.lines().any(|name| name == "PATH"),
2393            "PATH must cross:\n{names}"
2394        );
2395
2396        let (ok, managed) = run_shell_command(
2397            dir.path(),
2398            "printf 'HOME=%s\nKRANZ_BASE_SHA=%s\nCARGO_HOME=%s\n' \"$HOME\" \"$KRANZ_BASE_SHA\" \"$CARGO_HOME\"",
2399            &env,
2400        )
2401        .await;
2402        assert!(ok, "{managed}");
2403        assert!(
2404            managed.contains(&format!("HOME={}", scratch.path().display())),
2405            "HOME must be the per-mission scratch:\n{managed}"
2406        );
2407        assert!(
2408            managed.contains("KRANZ_BASE_SHA=deadbeef"),
2409            "base sha must reach the contract env:\n{managed}"
2410        );
2411        let cargo_home = env.get("CARGO_HOME").expect("CARGO_HOME");
2412        assert!(
2413            std::path::Path::new(cargo_home).starts_with(scratch.path()),
2414            "contract CARGO_HOME must live under mission scratch: {cargo_home}"
2415        );
2416        assert!(
2417            managed.contains(&format!("CARGO_HOME={cargo_home}")),
2418            "cache-only Cargo home must reach the child:\n{managed}"
2419        );
2420    }
2421
2422    /// agent-env-clear design 4: `contractEnvPassthrough` admits EXACTLY the
2423    /// named ambient var — and only when configured.
2424    #[cfg(unix)]
2425    #[tokio::test]
2426    async fn contract_env_passthrough_admits_only_the_named_var() {
2427        let _guard = crate::agent_env::EnvTestGuard::engage(&[
2428            ("KRANZ_CONTRACT_TEST_CRED", "cred-value"),
2429            ("GH_TOKEN", "hunter2"),
2430        ]);
2431        let dir = tempfile::tempdir().unwrap();
2432        let scratch = tempfile::tempdir().unwrap();
2433
2434        // Not configured: the var does NOT cross.
2435        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
2436        let (ok, output) =
2437            run_shell_command(dir.path(), "test -z \"$KRANZ_CONTRACT_TEST_CRED\"", &env).await;
2438        assert!(
2439            ok,
2440            "an unconfigured var must not reach the contract env: {output}"
2441        );
2442
2443        // Configured: exactly that var crosses, with its value; GH_TOKEN
2444        // still does not.
2445        let env = crate::agent_env::contract_command_env(
2446            scratch.path(),
2447            None,
2448            &["KRANZ_CONTRACT_TEST_CRED".to_string()],
2449        );
2450        let (ok, output) = run_shell_command(
2451            dir.path(),
2452            "test \"$KRANZ_CONTRACT_TEST_CRED\" = cred-value && test -z \"$GH_TOKEN\"",
2453            &env,
2454        )
2455        .await;
2456        assert!(
2457            ok,
2458            "the passthrough-named var must cross, nothing else: {output}"
2459        );
2460    }
2461
2462    /// The final gate's command executor must carry the same
2463    /// KRANZ_BASE_SHA env that worker/validator sessions get, via the one
2464    /// shared `runner::contract_env` constructor (mission m-d341a7's false
2465    /// CRITICAL came from this gate omitting it).
2466    #[cfg(unix)]
2467    #[tokio::test]
2468    async fn base_sha_reaches_final_gate_env() {
2469        let dir = tempfile::tempdir().unwrap();
2470        let env = runner::contract_env(Some("deadbeefcafe"));
2471        let (ok, output) = run_shell_command_with_timeout(
2472            dir.path(),
2473            "test \"$KRANZ_BASE_SHA\" = deadbeefcafe",
2474            Duration::from_secs(10),
2475            &env,
2476        )
2477        .await;
2478        assert!(ok, "expected command to succeed: {output}");
2479    }
2480
2481    /// The exit-code variant surfaces the real failure code (`Some(n)`) and
2482    /// keeps `Some(0)` as the only success — the workspace gate's block
2483    /// reasons name it (`exit code 3`), and a nonzero code must never map to
2484    /// success. Commands stay `sh`/`cmd` portable (`echo`, `exit`).
2485    #[tokio::test]
2486    async fn shell_command_with_code_reports_the_real_exit_code() {
2487        let dir = tempfile::tempdir().unwrap();
2488        let env = std::collections::HashMap::new();
2489
2490        let (code, output) = run_shell_command_with_code(dir.path(), "echo hi", &env).await;
2491        assert_eq!(code, Some(0), "{output}");
2492        assert!(output.contains("hi"), "{output}");
2493
2494        let (code, output) = run_shell_command_with_code(dir.path(), "exit 3", &env).await;
2495        assert_eq!(code, Some(3), "{output}");
2496    }
2497
2498    /// The preflight argv runner shares the bounded core: a timeout SIGKILLs
2499    /// the whole process GROUP, not just the direct child — a backgrounded
2500    /// grandchild must not survive. Unix-only (`kill(-pgid)`); the Windows
2501    /// equivalent goes through the kill-on-close Job Object in
2502    /// `run_command_bounded`, validated by windows-latest CI.
2503    #[cfg(unix)]
2504    #[tokio::test]
2505    async fn bounded_argv_timeout_kills_the_whole_process_tree() {
2506        let dir = tempfile::tempdir().unwrap();
2507        let pidfile = dir.path().join("child.pid");
2508        let script = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2509        let env = std::collections::HashMap::new();
2510
2511        let (code, output) = tokio::time::timeout(
2512            Duration::from_secs(10),
2513            run_bounded_argv(
2514                dir.path(),
2515                std::path::Path::new("/bin/sh"),
2516                &["-c".to_string(), script],
2517                Duration::from_millis(500),
2518                &env,
2519            ),
2520        )
2521        .await
2522        .expect("timed-out command must return promptly");
2523        assert_eq!(code, None, "a timeout yields no exit code: {output}");
2524        assert!(output.contains("timed out"), "got: {output}");
2525
2526        let pid: i32 = std::fs::read_to_string(&pidfile)
2527            .expect("shell wrote the background pid before the timeout")
2528            .trim()
2529            .parse()
2530            .expect("pidfile contains a pid");
2531
2532        // The group SIGKILL must take the background child down: poll until
2533        // kill(pid, 0) no longer reports it (dead + reaped by init), bounded.
2534        let deadline = std::time::Instant::now() + Duration::from_secs(5);
2535        while unsafe { libc::kill(pid, 0) } == 0 {
2536            assert!(
2537                std::time::Instant::now() < deadline,
2538                "background child {pid} survived the group kill"
2539            );
2540            tokio::time::sleep(Duration::from_millis(50)).await;
2541        }
2542    }
2543
2544    /// The preflight argv runner drains both pipes CONCURRENTLY with the
2545    /// wait: a command emitting far more than the 64KB pipe buffer completes
2546    /// instead of deadlocking, and only the capped tail is retained. Real
2547    /// exit codes pass through (`Some(3)`), `Some(0)` stays the only success.
2548    #[cfg(unix)]
2549    #[tokio::test]
2550    async fn bounded_argv_drains_large_output_and_reports_exit_codes() {
2551        let dir = tempfile::tempdir().unwrap();
2552        let env = std::collections::HashMap::new();
2553        let big = "i=0; while [ \"$i\" -lt 20000 ]; do \
2554                   printf '0123456789abcdef0123456789abcdef\\n'; \
2555                   i=$((i + 1)); done; printf 'OUTPUT-END'";
2556
2557        let (code, output) = run_bounded_argv(
2558            dir.path(),
2559            std::path::Path::new("/bin/sh"),
2560            &["-c".to_string(), big.to_string()],
2561            Duration::from_secs(10),
2562            &env,
2563        )
2564        .await;
2565
2566        assert_eq!(
2567            code,
2568            Some(0),
2569            "large-output command must complete: {output}"
2570        );
2571        assert!(output.ends_with("OUTPUT-END"), "{output}");
2572        assert!(
2573            output.chars().count() <= COMMAND_OUTPUT_TAIL,
2574            "retained output exceeded the cap: {} chars",
2575            output.chars().count()
2576        );
2577
2578        let (code, output) = run_bounded_argv(
2579            dir.path(),
2580            std::path::Path::new("/bin/sh"),
2581            &["-c".to_string(), "exit 3".to_string()],
2582            Duration::from_secs(10),
2583            &env,
2584        )
2585        .await;
2586        assert_eq!(code, Some(3), "{output}");
2587    }
2588
2589    // -----------------------------------------------------------------------
2590    // Gate sandbox wrap (ticket engine-gates-sandbox-wrapped). The
2591    // enforcement tests spawn the real platform sandbox (sandbox-exec /
2592    // bwrap) and skip cleanly where it cannot apply — the same posture as
2593    // crate::sandbox's own enforcement tests.
2594    // -----------------------------------------------------------------------
2595
2596    /// Serializes the enforcement probes below (sandbox-exec/bwrap spawn
2597    /// contention made these flaky unguarded — mirrors
2598    /// `crate::sandbox`'s SANDBOX_EXEC_TEST_LOCK).
2599    #[cfg(unix)]
2600    static GATE_SANDBOX_WRAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2601
2602    #[cfg(target_os = "macos")]
2603    fn gate_wrap_sandbox_exec_can_apply() -> bool {
2604        let found = std::process::Command::new("which")
2605            .arg("sandbox-exec")
2606            .output()
2607            .map(|o| o.status.success())
2608            .unwrap_or(false);
2609        if !found {
2610            crate::test_capability::skip(
2611                crate::test_capability::capability::SANDBOX_EXEC,
2612                "sandbox-exec not found on this host",
2613            );
2614            return false;
2615        }
2616        let smoke = std::process::Command::new("sandbox-exec")
2617            .arg("-p")
2618            .arg("(version 1)\n(allow default)\n")
2619            .arg("/usr/bin/true")
2620            .output();
2621        match smoke {
2622            Ok(output) if output.status.success() => true,
2623            Ok(output) => {
2624                eprintln!(
2625                    "sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
2626                    String::from_utf8_lossy(&output.stderr)
2627                );
2628                false
2629            }
2630            Err(e) => {
2631                eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
2632                false
2633            }
2634        }
2635    }
2636
2637    #[cfg(target_os = "linux")]
2638    fn gate_wrap_bwrap_can_apply() -> bool {
2639        if !crate::sandbox::command_available("bwrap") {
2640            crate::test_capability::skip(
2641                crate::test_capability::capability::BWRAP,
2642                "bwrap not found on this host",
2643            );
2644            return false;
2645        }
2646        let smoke = std::process::Command::new("bwrap")
2647            .args([
2648                "--die-with-parent",
2649                "--ro-bind",
2650                "/",
2651                "/",
2652                "--dev",
2653                "/dev",
2654                "--proc",
2655                "/proc",
2656                "--",
2657                "/bin/true",
2658            ])
2659            .output();
2660        match smoke {
2661            Ok(output) if output.status.success() => true,
2662            Ok(output) => {
2663                eprintln!(
2664                    "bwrap cannot apply a smoke sandbox on this host; skipping: {}",
2665                    String::from_utf8_lossy(&output.stderr)
2666                );
2667                false
2668            }
2669            Err(e) => {
2670                eprintln!("bwrap smoke probe failed; skipping: {e}");
2671                false
2672            }
2673        }
2674    }
2675
2676    /// Whether THIS host can apply the resolved gate wrap (the enforcement
2677    /// tests skip where it cannot — CI linux runners may lack bwrap).
2678    #[cfg(unix)]
2679    fn gate_wrap_enforcement_available() -> bool {
2680        #[cfg(target_os = "macos")]
2681        {
2682            gate_wrap_sandbox_exec_can_apply()
2683        }
2684        #[cfg(target_os = "linux")]
2685        {
2686            gate_wrap_bwrap_can_apply()
2687        }
2688        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2689        {
2690            false
2691        }
2692    }
2693
2694    fn fs_sandbox_config(enforce: crate::types::SandboxEnforce) -> crate::types::SandboxConfig {
2695        crate::types::SandboxConfig {
2696            enforce,
2697            provider: crate::types::SandboxProvider::Process,
2698            image: None,
2699            extra_write: vec![],
2700            egress: vec![],
2701        }
2702    }
2703
2704    /// A repo-shaped layout for the gate wrap probes: `<repo>/.kranz` with
2705    /// authority material, `<repo>/.kranz/missions/m-gate` with engine-owned
2706    /// metadata, and a public file — the checkout-mode hostile shape where
2707    /// the gate cwd is an ANCESTOR of the mission dir.
2708    #[cfg(unix)]
2709    fn gate_wrap_layout() -> (tempfile::TempDir, std::path::PathBuf) {
2710        gate_wrap_layout_with_repo(tempfile::tempdir().unwrap())
2711    }
2712
2713    #[cfg(unix)]
2714    fn gate_wrap_layout_with_repo(
2715        repo: tempfile::TempDir,
2716    ) -> (tempfile::TempDir, std::path::PathBuf) {
2717        let kranz_dir = repo.path().join(".kranz");
2718        let mission = kranz_dir.join("missions").join("m-gate");
2719        std::fs::create_dir_all(mission.join("runs")).unwrap();
2720        std::fs::create_dir_all(mission.join("control")).unwrap();
2721        std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
2722        std::fs::write(mission.join("state.json"), "{}").unwrap();
2723        for name in ["serve.token", "serve.read.token", "config.json"] {
2724            std::fs::write(kranz_dir.join(name), "secret").unwrap();
2725        }
2726        std::fs::write(repo.path().join("public.txt"), "public").unwrap();
2727        (repo, mission)
2728    }
2729
2730    /// The resolve matrix, pure and cross-platform: off stays disabled (no
2731    /// note), macOS resolves Seatbelt (profile file written, `/dev/null`
2732    /// allow appended, NO xcrun write allow — 13th-pass prewarm + deny,
2733    /// denies + writable roots in shape), linux resolves Bubblewrap and
2734    /// fails CLOSED without bwrap, Windows resolves AppContainer, an unknown
2735    /// platform fails CLOSED, and the container provider wraps in the mission
2736    /// container with a runtime and fails CLOSED without one (ticket
2737    /// container-gate-wrapper).
2738    #[test]
2739    fn gate_sandbox_wrap_resolve_matrix() {
2740        let repo = tempfile::tempdir().unwrap();
2741        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2742        std::fs::create_dir_all(&mission).unwrap();
2743        let scratch = tempfile::tempdir().unwrap();
2744        let off = crate::types::SandboxConfig::default();
2745        let fs = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
2746
2747        // off → Disabled, no note, on every platform.
2748        let resolution = resolve_gate_sandbox_target(
2749            &off,
2750            repo.path(),
2751            &mission,
2752            scratch.path(),
2753            scratch.path(),
2754            "macos",
2755            false,
2756            None,
2757            None,
2758        )
2759        .unwrap();
2760        assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
2761        assert!(resolution.note.is_none());
2762
2763        // fs on macOS → Seatbelt: profile written, gate device allow
2764        // appended, session denies/writable roots reused.
2765        let resolution = resolve_gate_sandbox_target(
2766            &fs,
2767            repo.path(),
2768            &mission,
2769            scratch.path(),
2770            scratch.path(),
2771            "macos",
2772            false,
2773            None,
2774            None,
2775        )
2776        .unwrap();
2777        assert!(resolution.note.is_none());
2778        let GateSandbox::Seatbelt {
2779            enforce,
2780            profile_path,
2781        } = &resolution.sandbox
2782        else {
2783            panic!("fs on macOS must resolve to Seatbelt");
2784        };
2785        assert_eq!(*enforce, crate::types::SandboxEnforce::Fs);
2786        let profile = std::fs::read_to_string(profile_path).unwrap();
2787        assert!(profile.contains("(deny default)"), "{profile}");
2788        assert!(
2789            profile.contains("(literal \"/dev/null\")"),
2790            "the gate profile must add the /dev/null device write allow:\n{profile}"
2791        );
2792        assert!(
2793            profile.contains("(literal \"/dev/ptmx\")"),
2794            "pty harness support (pty-functional-validation): the gate profile must \
2795             permit the ptmx multiplexer:\n{profile}"
2796        );
2797        // 14th-pass review (ticket gate-wrap-file-ioctl-unscoped): the ioctl
2798        // allow is pinned SCOPED to the pty device pair — a bare
2799        // `(allow file-ioctl)` re-widen must fail loudly here.
2800        assert!(
2801            profile.contains(
2802                "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2803            ),
2804            "the grantpt/unlockpt ioctl allow must be scoped to /dev/ptmx and the \
2805             tty slave nodes:\n{profile}"
2806        );
2807        assert!(
2808            !profile.contains("(allow file-ioctl)"),
2809            "the ioctl allow must never be unscoped again (every device the gate \
2810             can open becomes ioctl-able):\n{profile}"
2811        );
2812        assert!(
2813            !profile.contains("xcrun_db"),
2814            "13th-pass review (P1): the gate profile must NOT permit writes to the \
2815             shared per-user xcrun cache (prewarm + deny posture):\n{profile}"
2816        );
2817        assert!(
2818            profile.contains("events.jsonl"),
2819            "mission metadata write denies must ride along:\n{profile}"
2820        );
2821        assert!(
2822            profile.contains("serve.token"),
2823            "authority read denies must ride along:\n{profile}"
2824        );
2825
2826        // fs on linux with bwrap → Bubblewrap inputs shaped like the gate.
2827        let resolution = resolve_gate_sandbox_target(
2828            &fs,
2829            repo.path(),
2830            &mission,
2831            scratch.path(),
2832            scratch.path(),
2833            "linux",
2834            true,
2835            None,
2836            None,
2837        )
2838        .unwrap();
2839        let GateSandbox::Bubblewrap { inputs } = &resolution.sandbox else {
2840            panic!("fs on linux with bwrap must resolve to Bubblewrap");
2841        };
2842        assert_eq!(inputs.session_cwd, repo.path());
2843        assert_eq!(inputs.tmpdir, scratch.path());
2844        assert_eq!(inputs.mission_dir, mission);
2845
2846        // fs on linux WITHOUT bwrap → fail closed, naming bwrap (mirrors
2847        // session resolution; never a silent unsandboxed gate).
2848        let error = resolve_gate_sandbox_target(
2849            &fs,
2850            repo.path(),
2851            &mission,
2852            scratch.path(),
2853            scratch.path(),
2854            "linux",
2855            false,
2856            None,
2857            None,
2858        )
2859        .expect_err("linux without bwrap must fail closed");
2860        assert!(error.to_string().contains("bwrap"), "{error}");
2861
2862        // fs on Windows → stable AppContainer inputs shaped like the gate.
2863        let resolution = resolve_gate_sandbox_target(
2864            &fs,
2865            repo.path(),
2866            &mission,
2867            scratch.path(),
2868            scratch.path(),
2869            "windows",
2870            false,
2871            None,
2872            None,
2873        )
2874        .expect("Windows process gates resolve AppContainer");
2875        let GateSandbox::AppContainer { inputs, .. } = &resolution.sandbox else {
2876            panic!("fs on Windows must resolve AppContainer");
2877        };
2878        assert_eq!(inputs.session_cwd, repo.path());
2879        assert_eq!(inputs.tmpdir, scratch.path());
2880        assert_eq!(inputs.mission_dir, mission);
2881
2882        // fs on an unknown platform → FAIL CLOSED (13th-pass review,
2883        // P1): agent sessions already refuse to run there, and a standalone
2884        // merge gate must fail loudly too — never run unsandboxed under an
2885        // enforced config.
2886        let error = resolve_gate_sandbox_target(
2887            &fs,
2888            repo.path(),
2889            &mission,
2890            scratch.path(),
2891            scratch.path(),
2892            "solaris",
2893            false,
2894            None,
2895            None,
2896        )
2897        .expect_err("an unknown platform must fail closed");
2898        assert!(error.to_string().contains("unsupported"), "{error}");
2899        assert!(
2900            error
2901                .to_string()
2902                .contains("refusing to run engine-run gates unsandboxed"),
2903            "{error}"
2904        );
2905    }
2906
2907    /// The pty-era extras, pinned as TEXT (ticket
2908    /// gate-wrap-file-ioctl-unscoped, 14th-pass review): the file-ioctl
2909    /// allow must stay scoped to exactly the pty device pair the harness
2910    /// needs — `/dev/ptmx` (grantpt/unlockpt land on the master fd) plus
2911    /// the tty-slave regex (termios/winsize on the slave) — so a future
2912    /// re-widen to the unrestricted `(allow file-ioctl)` fails loudly.
2913    /// Scoped-for-every-gate is deliberate: the gate profile cannot know at
2914    /// resolve time whether the contract carries pty assertions (merge
2915    /// gates never see one), and the scoped surface is the pty pair alone.
2916    #[test]
2917    fn gate_profile_extras_scopes_file_ioctl_to_pty_devices() {
2918        let extras = gate_profile_extras();
2919        assert!(
2920            extras.contains(
2921                "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2922            ),
2923            "the ioctl allow must be scoped to the pty device pair:\n{extras}"
2924        );
2925        assert!(
2926            !extras.contains("(allow file-ioctl)"),
2927            "the unrestricted ioctl allow must not return:\n{extras}"
2928        );
2929        // The rest of the pty surface stays (multiplexer read+write, slave
2930        // read+write) — the scoped ioctl is useless without them.
2931        assert!(extras.contains("(literal \"/dev/ptmx\")"), "{extras}");
2932        assert!(extras.contains("^/dev/tty[p-t][0-9a-f]+$"), "{extras}");
2933        assert!(
2934            extras.contains("(allow signal (target same-sandbox))"),
2935            "{extras}"
2936        );
2937    }
2938
2939    /// H7 (2026-09-01 adversarial audit): the scoped regex above still
2940    /// matched the OPERATOR's own terminal — on macOS the pty slave pool IS
2941    /// the terminal pool, and `/dev/ttys003` is matched by
2942    /// `^/dev/tty[p-t][0-9a-f]+$`. The tests before this one asserted the
2943    /// PRESENCE of the scoped grant and so locked the bug in. The device
2944    /// class stays allowed (openpty needs it); the parent's own terminal is
2945    /// denied by name after it, and SBPL denies beat allows.
2946    #[test]
2947    fn gate_profile_extras_deny_the_operators_own_terminal() {
2948        let extras = gate_profile_extras();
2949        let ttys = crate::sandbox::operator_tty_paths();
2950        if ttys.is_empty() {
2951            // No controlling terminal (CI, `kranz serve`, a detached test
2952            // runner): there is nothing to protect, and the profile must
2953            // stay byte-identical to the pre-audit shape rather than emit an
2954            // empty deny block.
2955            assert!(
2956                !extras.contains("(deny file-read* file-write* file-ioctl"),
2957                "no tty means no deny block:\n{extras}"
2958            );
2959            return;
2960        }
2961        assert!(
2962            extras.contains("(deny file-read* file-write* file-ioctl"),
2963            "a controlling terminal must produce a deny block:\n{extras}"
2964        );
2965        for tty in &ttys {
2966            let expected = format!("(literal \"{}\")", crate::sandbox::escape_sbpl_literal(tty));
2967            assert!(
2968                extras.contains(&expected),
2969                "the operator terminal {} must be denied:\n{extras}",
2970                tty.display()
2971            );
2972        }
2973        // The deny lands AFTER the pty allows, which is where the audit's
2974        // fix sketch put it (documentary — denies win regardless of order).
2975        let allow = extras
2976            .find("(allow file-ioctl (literal \"/dev/ptmx\")")
2977            .expect("the pty ioctl allow");
2978        let deny = extras
2979            .find("(deny file-read* file-write* file-ioctl")
2980            .expect("the terminal deny");
2981        assert!(deny > allow, "the deny must follow the allows:\n{extras}");
2982    }
2983
2984    /// The same deny rides the WORKER/session profile, not only wrapped
2985    /// gates: an agent session under Seatbelt is the other process that
2986    /// could reach the operator's terminal through the broad read allow.
2987    #[test]
2988    fn session_profile_denies_the_operators_own_terminal() {
2989        let repo = tempfile::tempdir().unwrap();
2990        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2991        std::fs::create_dir_all(&mission).unwrap();
2992        let scratch = tempfile::tempdir().unwrap();
2993        let profile = crate::sandbox::generate_profile(&crate::sandbox::SandboxInputs {
2994            enforce: crate::types::SandboxEnforce::Fs,
2995            session_cwd: repo.path().to_path_buf(),
2996            mission_dir: mission,
2997            tmpdir: scratch.path().to_path_buf(),
2998            extra_write: vec![],
2999            egress: vec![],
3000            validator_read_deny_roots: vec![],
3001        });
3002
3003        for tty in crate::sandbox::operator_tty_paths() {
3004            let expected = format!(
3005                "(literal \"{}\")",
3006                crate::sandbox::escape_sbpl_literal(&tty)
3007            );
3008            assert!(
3009                profile.contains(&expected),
3010                "the session profile must deny the operator terminal {}:\n{profile}",
3011                tty.display()
3012            );
3013        }
3014    }
3015
3016    /// The container arm of the resolve matrix (ticket container-gate-wrapper):
3017    /// provider:container + enforce != off + a detected runtime resolves to
3018    /// [`GateSandbox::Container`] with gate-shaped inputs (the gate cwd as the
3019    /// writable root, the scratch as tmpdir, the mission dir for the metadata
3020    /// denies) and the configured/default image — on the live-proven Linux
3021    /// host. macOS and Windows fail closed even when a runtime exists:
3022    /// runtime presence does not prove guest path or authority-mask semantics.
3023    /// No runtime FAILS CLOSED with the shared note (mirroring session
3024    /// resolution — never a silent host-side gate); `fs+net` with a non-empty
3025    /// egress list FAILS CLOSED (advisory-only on the bridge, and no egress
3026    /// proxy exists engine-side); `enforce: off` stays Disabled.
3027    #[test]
3028    fn container_gate_wrap_resolve_matrix() {
3029        let repo = tempfile::tempdir().unwrap();
3030        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3031        std::fs::create_dir_all(&mission).unwrap();
3032        let scratch = tempfile::tempdir().unwrap();
3033        let container = |enforce| crate::types::SandboxConfig {
3034            enforce,
3035            provider: crate::types::SandboxProvider::Container,
3036            image: None,
3037            extra_write: vec![],
3038            egress: vec![],
3039        };
3040        let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3041
3042        // A detected runtime → the container wrap on the live-proven host.
3043        let resolution = resolve_gate_sandbox_target(
3044            &container(crate::types::SandboxEnforce::Fs),
3045            repo.path(),
3046            &mission,
3047            scratch.path(),
3048            scratch.path(),
3049            "linux",
3050            false,
3051            runtime,
3052            None,
3053        )
3054        .unwrap();
3055        assert!(resolution.note.is_none());
3056        let GateSandbox::Container { inputs, spec } = &resolution.sandbox else {
3057            panic!("container + runtime must resolve to GateSandbox::Container on linux");
3058        };
3059        assert_eq!(inputs.session_cwd, repo.path());
3060        assert_eq!(inputs.tmpdir, scratch.path());
3061        assert_eq!(inputs.mission_dir, mission);
3062        assert_eq!(inputs.enforce, crate::types::SandboxEnforce::Fs);
3063        assert_eq!(
3064            spec.runtime,
3065            crate::sandbox_container::ContainerRuntime::Docker
3066        );
3067        assert_eq!(spec.image, crate::sandbox_container::DEFAULT_IMAGE);
3068
3069        // A proven host resolves its gates exactly as it resolves its
3070        // sessions. Without this the two disagree, and a mission runs its
3071        // worker contained and then fails at its own merge gate.
3072        let proven = resolve_gate_sandbox_target(
3073            &container(crate::types::SandboxEnforce::Fs),
3074            repo.path(),
3075            &mission,
3076            scratch.path(),
3077            scratch.path(),
3078            "macos",
3079            false,
3080            runtime,
3081            Some(crate::sandbox_container::MountProof::Proven),
3082        )
3083        .expect("a proven macOS host must resolve its container gate");
3084        assert!(
3085            matches!(proven.sandbox, GateSandbox::Container { .. }),
3086            "{:?}",
3087            proven.sandbox
3088        );
3089
3090        // A host whose mount shares nothing is refused with the path, not a
3091        // platform verdict.
3092        let unshared = resolve_gate_sandbox_target(
3093            &container(crate::types::SandboxEnforce::Fs),
3094            repo.path(),
3095            &mission,
3096            scratch.path(),
3097            scratch.path(),
3098            "macos",
3099            false,
3100            runtime,
3101            Some(crate::sandbox_container::MountProof::Failed(
3102                "docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
3103            )),
3104        )
3105        .expect_err("a failed proof must refuse the gate");
3106        assert!(
3107            unshared.to_string().contains("/var/folders/x"),
3108            "{unshared}"
3109        );
3110
3111        // Runtime presence is not containment evidence on an unproved host.
3112        // Both platforms refuse before the gate process starts; macOS points
3113        // to its supported native Seatbelt path.
3114        for target_os in ["macos", "windows"] {
3115            let error = resolve_gate_sandbox_target(
3116                &container(crate::types::SandboxEnforce::Fs),
3117                repo.path(),
3118                &mission,
3119                scratch.path(),
3120                scratch.path(),
3121                target_os,
3122                false,
3123                runtime,
3124                None,
3125            )
3126            .expect_err("an unproved container gate must fail closed");
3127            assert!(
3128                error
3129                    .to_string()
3130                    .contains("unverified container mount contract"),
3131                "{error}"
3132            );
3133            if target_os == "macos" {
3134                assert!(
3135                    error.to_string().contains("requires a bind-mount proof"),
3136                    "{error}"
3137                );
3138                assert!(
3139                    error.to_string().contains("sandbox.provider=\"process\""),
3140                    "{error}"
3141                );
3142            }
3143        }
3144
3145        // A configured image rides into the spec (the mission container
3146        // image carries the gate's toolchain — the documented assumption).
3147        let mut imaged = container(crate::types::SandboxEnforce::Fs);
3148        imaged.image = Some("ghcr.io/example/kranz-worker:1".to_string());
3149        let resolution = resolve_gate_sandbox_target(
3150            &imaged,
3151            repo.path(),
3152            &mission,
3153            scratch.path(),
3154            scratch.path(),
3155            "linux",
3156            false,
3157            runtime,
3158            None,
3159        )
3160        .unwrap();
3161        let GateSandbox::Container { spec, .. } = &resolution.sandbox else {
3162            panic!("container + runtime must resolve to GateSandbox::Container");
3163        };
3164        assert_eq!(spec.image, "ghcr.io/example/kranz-worker:1");
3165
3166        // NO runtime → FAIL CLOSED with the shared note text (the same text
3167        // MergeGatePolicy::degradation_note surfaces on the merge path).
3168        let error = resolve_gate_sandbox_target(
3169            &container(crate::types::SandboxEnforce::Fs),
3170            repo.path(),
3171            &mission,
3172            scratch.path(),
3173            scratch.path(),
3174            "linux",
3175            false,
3176            None,
3177            None,
3178        )
3179        .expect_err("container without a runtime must fail closed");
3180        assert!(
3181            error.to_string().contains("no container runtime"),
3182            "{error}"
3183        );
3184        assert!(
3185            error
3186                .to_string()
3187                .contains("refusing to run engine-run gates unsandboxed"),
3188            "{error}"
3189        );
3190
3191        // fs+net with a NON-EMPTY egress list → FAIL CLOSED: advisory-only
3192        // on the runtime bridge and no egress proxy exists engine-side, so
3193        // the gate must never silently keep the bridge.
3194        let mut egress = container(crate::types::SandboxEnforce::FsNet);
3195        egress.egress = vec!["crates.io:443".to_string()];
3196        let error = resolve_gate_sandbox_target(
3197            &egress,
3198            repo.path(),
3199            &mission,
3200            scratch.path(),
3201            scratch.path(),
3202            "linux",
3203            false,
3204            runtime,
3205            None,
3206        )
3207        .expect_err("container fs+net with an egress list must fail closed");
3208        assert!(error.to_string().contains("advisory"), "{error}");
3209
3210        // fs+net with an EMPTY egress list wraps (`--network none` is the
3211        // hard boundary) — and the wrap carries fs+net for the runner's
3212        // offline-by-cache env adjustment.
3213        let resolution = resolve_gate_sandbox_target(
3214            &container(crate::types::SandboxEnforce::FsNet),
3215            repo.path(),
3216            &mission,
3217            scratch.path(),
3218            scratch.path(),
3219            "linux",
3220            false,
3221            runtime,
3222            None,
3223        )
3224        .unwrap();
3225        assert_eq!(
3226            resolution.sandbox.enforce(),
3227            crate::types::SandboxEnforce::FsNet
3228        );
3229
3230        // enforce: off + container → Disabled, no note (the off check
3231        // precedes the provider — no runtime is required either).
3232        let resolution = resolve_gate_sandbox_target(
3233            &container(crate::types::SandboxEnforce::Off),
3234            repo.path(),
3235            &mission,
3236            scratch.path(),
3237            scratch.path(),
3238            "macos",
3239            false,
3240            None,
3241            None,
3242        )
3243        .unwrap();
3244        assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3245        assert!(resolution.note.is_none());
3246    }
3247
3248    /// M7 Windows parity, phase 4: engine-run process gates resolve the stable
3249    /// AppContainer wrapper. A detected `docker.exe` still does not prove the
3250    /// Windows container mount/authority-mask contract, so that provider
3251    /// continues to fail closed.
3252    #[test]
3253    fn windows_enforced_gate_process_resolves_appcontainer_while_container_fails_closed() {
3254        let repo = tempfile::tempdir().unwrap();
3255        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3256        std::fs::create_dir_all(&mission).unwrap();
3257        let scratch = tempfile::tempdir().unwrap();
3258        let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3259
3260        for enforce in [
3261            crate::types::SandboxEnforce::Fs,
3262            crate::types::SandboxEnforce::FsNet,
3263        ] {
3264            let process = fs_sandbox_config(enforce);
3265            let resolution = resolve_gate_sandbox_target(
3266                &process,
3267                repo.path(),
3268                &mission,
3269                scratch.path(),
3270                scratch.path(),
3271                "windows",
3272                false,
3273                runtime,
3274                None,
3275            )
3276            .expect("Windows process gate enforcement resolves");
3277            assert!(resolution.note.is_none(), "{:?}", resolution.note);
3278            let GateSandbox::AppContainer { inputs, .. } = resolution.sandbox else {
3279                panic!("Windows process gate must resolve AppContainer");
3280            };
3281            assert_eq!(inputs.enforce, enforce);
3282            assert_eq!(inputs.session_cwd, repo.path());
3283            assert_eq!(inputs.mission_dir, mission);
3284
3285            let container = crate::types::SandboxConfig {
3286                enforce,
3287                provider: crate::types::SandboxProvider::Container,
3288                image: None,
3289                extra_write: vec![],
3290                egress: vec![],
3291            };
3292            let error = resolve_gate_sandbox_target(
3293                &container,
3294                repo.path(),
3295                &mission,
3296                scratch.path(),
3297                scratch.path(),
3298                "windows",
3299                false,
3300                runtime,
3301                None,
3302            )
3303            .expect_err("an unproved Windows container gate must fail closed");
3304            // Windows is refused on its own contract gap, not for want of a
3305            // proof: no probe result could change this answer.
3306            assert!(error
3307                .to_string()
3308                .contains("not supported on target_os=windows"));
3309            assert!(error
3310                .to_string()
3311                .contains("unverified container mount contract"));
3312        }
3313    }
3314
3315    /// 13th-pass review (P1), the prewarm half of the macOS xcrun posture:
3316    /// the shim cache is refreshed OUTSIDE the sandbox ONCE PER RESOLVE —
3317    /// never per command (the cache is per-user and shared, so one refresh
3318    /// covers every wrapped spawn the resolution produces). Counted through
3319    /// the GATE_XCRUN_PREWARM_SPAWNS test seam. macOS-only: the prewarm is
3320    /// compiled out elsewhere.
3321    #[cfg(target_os = "macos")]
3322    #[test]
3323    fn gate_xcrun_deny_prewarm_runs_once_per_resolve_not_per_command() {
3324        let repo = tempfile::tempdir().unwrap();
3325        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3326        std::fs::create_dir_all(&mission).unwrap();
3327        let scratch = tempfile::tempdir().unwrap();
3328        let cfg = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
3329        let resolve = || {
3330            resolve_gate_sandbox(&cfg, repo.path(), &mission, scratch.path(), scratch.path())
3331                .unwrap()
3332        };
3333
3334        // Per-resolution state, never a global counter: a process-wide
3335        // counter races with parallel test threads resolving concurrently
3336        // (the rust-macos CI flake this replaced).
3337        let resolution = resolve();
3338        assert!(resolution.prewarmed_xcrun, "one prewarm per resolve");
3339
3340        // Wrapping commands from this resolution prewarms NOTHING further —
3341        // the wrap is argv construction, the prewarm lives in resolve.
3342        let env = std::collections::HashMap::new();
3343        let _argv_one = resolution.sandbox.wrap_shell("true", &env).unwrap();
3344        let _argv_two = resolution.sandbox.wrap_shell("echo hi", &env).unwrap();
3345        assert!(
3346            resolution.prewarmed_xcrun,
3347            "command wraps neither prewarm nor reset the record"
3348        );
3349
3350        // A second resolve prewarms again — per resolve, not once globally.
3351        let second = resolve();
3352        assert!(second.prewarmed_xcrun, "each resolve prewarms exactly once");
3353    }
3354
3355    /// Ticket container-gate-wrapper, the merge-policy half: a
3356    /// provider:container policy ENFORCES on every host (the pre-check
3357    /// routes into the sandboxed runner, which wraps the gate in the mission
3358    /// container when a runtime is detected), and the merge path's note
3359    /// fires ONLY for the fail-closed remainder — no runtime on PATH. The
3360    /// note text the policy logs and the resolve error the gate run fails
3361    /// with are the SAME text (one explanation on every path).
3362    #[test]
3363    fn container_gate_wrap_merge_policy_enforces_or_notes_the_fail_closed() {
3364        let container = |enforce| crate::types::SandboxConfig {
3365            enforce,
3366            provider: crate::types::SandboxProvider::Container,
3367            image: None,
3368            extra_write: vec![],
3369            egress: vec![],
3370        };
3371        let policy = MergeGatePolicy {
3372            sandbox: container(crate::types::SandboxEnforce::Fs),
3373            mission_dir: std::path::PathBuf::new(),
3374        };
3375        // Enforces on EVERY host (host-independent: the wrap needs a
3376        // runtime, not a platform tier; runtime-absent fails closed inside
3377        // the sandboxed runner rather than routing to the unsandboxed seam).
3378        assert!(policy.enforces_on_this_host());
3379        // With a runtime the gates wrap — nothing degraded, no note.
3380        assert!(policy
3381            .degradation_note_target(Some(crate::sandbox_container::ContainerRuntime::Docker))
3382            .is_none());
3383        // Without one the merge path MUST log the fail-closed note…
3384        let note = policy
3385            .degradation_note_target(None)
3386            .expect("the runtime-unavailable container posture must be noted");
3387        assert!(note.contains("no container runtime"), "{note}");
3388        assert!(
3389            note.contains("refusing to run engine-run gates unsandboxed"),
3390            "{note}"
3391        );
3392        // …and the resolve error the gate run then fails with carries the
3393        // SAME text verbatim (the EngineError::Config display prefix is the
3394        // error-variant decoration, not part of the note).
3395        let repo = tempfile::tempdir().unwrap();
3396        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3397        std::fs::create_dir_all(&mission).unwrap();
3398        let scratch = tempfile::tempdir().unwrap();
3399        let error = resolve_gate_sandbox_target(
3400            &policy.sandbox,
3401            repo.path(),
3402            &mission,
3403            scratch.path(),
3404            scratch.path(),
3405            "linux",
3406            false,
3407            None,
3408            None,
3409        )
3410        .expect_err("container without a runtime must fail closed");
3411        assert_eq!(
3412            error.to_string(),
3413            format!("configuration error: {note}"),
3414            "the engine-path resolve error and the merge-path note must match"
3415        );
3416
3417        // enforce: off + container: nothing to enforce, nothing to note. A
3418        // process-provider policy has no note either — it wraps, or fails
3419        // closed loudly.
3420        let off = MergeGatePolicy {
3421            sandbox: container(crate::types::SandboxEnforce::Off),
3422            mission_dir: std::path::PathBuf::new(),
3423        };
3424        assert!(off.degradation_note_target(None).is_none());
3425        assert!(!off.enforces_on_this_host());
3426        let process = MergeGatePolicy {
3427            sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3428            mission_dir: std::path::PathBuf::new(),
3429        };
3430        assert!(process.degradation_note_target(None).is_none());
3431    }
3432
3433    /// The off regression: `enforce == off` resolves to
3434    /// [`GateSandbox::Disabled`], and a command through the Disabled wrap
3435    /// behaves BYTE-IDENTICALLY to the pre-wrap runner — including a write
3436    /// OUTSIDE any allowlist succeeding (today's documented posture).
3437    #[cfg(unix)]
3438    #[tokio::test]
3439    async fn gate_sandbox_wrap_off_keeps_byte_identical_behavior() {
3440        let dir = tempfile::tempdir().unwrap();
3441        let outside = tempfile::tempdir().unwrap();
3442        let env = std::collections::HashMap::new();
3443
3444        let resolution = resolve_gate_sandbox(
3445            &crate::types::SandboxConfig::default(),
3446            dir.path(),
3447            dir.path(),
3448            dir.path(),
3449            dir.path(),
3450        )
3451        .unwrap();
3452        assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3453        assert!(resolution.note.is_none());
3454
3455        let marker = outside.path().join("gate_sandbox_wrap_off_marker");
3456        let command = format!("echo hi > '{}' && printf MARKER", marker.display());
3457        let (ok_reference, out_reference) = run_shell_command(dir.path(), &command, &env).await;
3458        let (ok_wrapped, out_wrapped) =
3459            run_shell_command_sandboxed(dir.path(), &command, &env, &GateSandbox::Disabled).await;
3460        assert!(ok_reference, "reference run failed: {out_reference}");
3461        assert!(ok_wrapped, "disabled wrap run failed: {out_wrapped}");
3462        assert_eq!(
3463            out_reference, out_wrapped,
3464            "the Disabled wrap must reproduce the pre-wrap runner byte-for-byte"
3465        );
3466        assert!(
3467            marker.exists(),
3468            "with enforce == off a write outside any allowlist succeeds (today's posture)"
3469        );
3470    }
3471
3472    /// The fake runtime records launch and teardown context without a daemon.
3473    #[cfg(unix)]
3474    #[tokio::test]
3475    #[allow(clippy::await_holding_lock)]
3476    async fn container_gate_runtime_context_survives_timeout_without_worker_or_ambient_secrets() {
3477        use std::os::unix::fs::PermissionsExt as _;
3478        let fixture = tempfile::tempdir().unwrap();
3479        let home = fixture.path().join("operator");
3480        let scratch = fixture.path().join("worker");
3481        std::fs::create_dir(&home).unwrap();
3482        std::fs::create_dir(&scratch).unwrap();
3483        let stub = fixture.path().join("docker");
3484        std::fs::write(&stub, format!(
3485            "#!/bin/sh\nprintf '%s\\n' \"$HOME\" \"$DOCKER_HOST\" \"${{GH_TOKEN-unset}}\" > '{}/'$1.env\nprintf '%s\\n' \"$@\" > '{}/'$1.args\nif [ \"$1\" = run ]; then sleep 30; fi\n",
3486            fixture.path().display(), fixture.path().display(),
3487        )).unwrap();
3488        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o700)).unwrap();
3489        let path = format!(
3490            "{}:{}",
3491            fixture.path().display(),
3492            std::env::var("PATH").unwrap_or_default()
3493        );
3494        let _guard = crate::agent_env::EnvTestGuard::engage(&[
3495            ("PATH", &path),
3496            ("HOME", home.to_str().unwrap()),
3497            ("DOCKER_HOST", "unix:///operator-context.sock"),
3498            ("GH_TOKEN", "host-secret"),
3499        ]);
3500        let sandbox = GateSandbox::Container {
3501            inputs: Box::new(crate::sandbox::SandboxInputs {
3502                enforce: crate::types::SandboxEnforce::Fs,
3503                session_cwd: scratch.clone(),
3504                mission_dir: scratch.join("mission"),
3505                tmpdir: scratch.clone(),
3506                extra_write: vec![],
3507                egress: vec![],
3508                validator_read_deny_roots: vec![],
3509            }),
3510            spec: crate::sandbox_container::ContainerSpec {
3511                runtime: crate::sandbox_container::ContainerRuntime::Docker,
3512                image: "fixture".to_string(),
3513                network: None,
3514                name: None,
3515            },
3516        };
3517        let env = HashMap::from([
3518            ("HOME".to_string(), scratch.display().to_string()),
3519            (
3520                "DOCKER_HOST".to_string(),
3521                "unix:///worker-request.sock".to_string(),
3522            ),
3523            ("WORKER_SENTINEL".to_string(), "allowed".to_string()),
3524        ]);
3525        let (code, output) = run_shell_command_sandboxed_with_code(
3526            &scratch,
3527            "true",
3528            Duration::from_millis(500),
3529            &env,
3530            &sandbox,
3531        )
3532        .await;
3533        assert_eq!(
3534            code, None,
3535            "the fixture must exercise timeout cleanup: {output}"
3536        );
3537        for action in ["run", "rm"] {
3538            assert_eq!(
3539                std::fs::read_to_string(fixture.path().join(format!("{action}.env"))).unwrap(),
3540                format!("{}\nunix:///operator-context.sock\nunset\n", home.display())
3541            );
3542        }
3543        let args = std::fs::read_to_string(fixture.path().join("run.args")).unwrap();
3544        assert!(args.contains("WORKER_SENTINEL=allowed"));
3545        assert!(args.contains("DOCKER_HOST=unix:///worker-request.sock"));
3546        assert!(!args.contains("host-secret"));
3547    }
3548
3549    /// The ticket's core test gate: a contract command run under
3550    /// `enforce != off` provably executes INSIDE the profile. A write outside
3551    /// the allowlist (a sibling temp dir, and a file directly in the SHARED
3552    /// system temp root — the sibling-of-scratch case
3553    /// `sandbox-writable-scope` closed) FAILS under enforcement and SUCCEEDS
3554    /// with `enforce == off`; mission metadata writes are denied
3555    /// (Seatbelt) or evaporate into the bwrap masks with the host bytes
3556    /// untouched; a read of a denied authority path fails (`test -s` is the
3557    /// cross-backend probe: Seatbelt refuses the open, bwrap's /dev/null mask
3558    /// reads back empty). The `/dev/null` redirect probe guards the gate
3559    /// profile's device-write addition.
3560    ///
3561    /// The outside-write probes deliberately do NOT use the ambient `$HOME`:
3562    /// unrelated suite tests poison it concurrently (a test once read a
3563    /// tempdir-shaped `$HOME` here and the off-arm probe failed
3564    /// "No such file or directory"). The merge-gate HOME question has its own
3565    /// deterministic test below with a guarded fake HOME.
3566    // await_holding_lock: the std guard serializes real sandbox-exec/bwrap
3567    // spawns across tests; each #[tokio::test] runs on its own OS thread with
3568    // its own runtime, and the guard is only ever acquired at test start — a
3569    // blocked test has no awaits in flight yet, so no deadlock is possible.
3570    #[cfg(unix)]
3571    #[tokio::test]
3572    #[allow(clippy::await_holding_lock)]
3573    async fn gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads() {
3574        let _guard = GATE_SANDBOX_WRAP_LOCK
3575            .lock()
3576            .unwrap_or_else(|p| p.into_inner());
3577        if !gate_wrap_enforcement_available() {
3578            return;
3579        }
3580
3581        let (repo, mission) = gate_wrap_layout();
3582        let kranz_dir = repo.path().join(".kranz");
3583        let scratch = tempfile::tempdir().unwrap();
3584        let outside = tempfile::tempdir().unwrap();
3585        // A marker directly in the SHARED system temp root: a sibling of the
3586        // gate's scratch, never under a writable root.
3587        let temp_root_marker =
3588            std::env::temp_dir().join(format!("kranz-gate-wrap-{}", uuid::Uuid::new_v4()));
3589
3590        let resolution = resolve_gate_sandbox(
3591            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3592            repo.path(),
3593            &mission,
3594            scratch.path(),
3595            scratch.path(),
3596        )
3597        .unwrap();
3598        assert!(resolution.note.is_none());
3599        let sandbox = resolution.sandbox;
3600        assert!(sandbox.enforce() == crate::types::SandboxEnforce::Fs);
3601        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3602
3603        // Writable shape: the gate cwd and the private scratch stay writable.
3604        for allowed in [
3605            repo.path().join("src.txt"),
3606            scratch.path().join("notes.txt"),
3607        ] {
3608            let (ok, output) = run_shell_command_sandboxed(
3609                repo.path(),
3610                &format!("echo ok > '{}'", allowed.display()),
3611                &env,
3612                &sandbox,
3613            )
3614            .await;
3615            assert!(
3616                ok && allowed.exists(),
3617                "write inside the gate roots must succeed: {output}"
3618            );
3619        }
3620
3621        // `/dev/null` redirects work (the gate profile's appended device
3622        // allow — without it `sh` fails the command at redirect setup).
3623        let (ok, output) =
3624            run_shell_command_sandboxed(repo.path(), "echo hi > /dev/null 2>&1", &env, &sandbox)
3625                .await;
3626        assert!(ok, "/dev/null redirect must succeed: {output}");
3627
3628        // Writes OUTSIDE the allowlist fail under enforcement…
3629        let outside_file = outside.path().join("gate_sandbox_wrap_marker");
3630        for probe in [
3631            format!("echo x > '{}'", outside_file.display()),
3632            format!("echo x > '{}'", temp_root_marker.display()),
3633        ] {
3634            let (ok, output) =
3635                run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
3636            assert!(
3637                !ok,
3638                "write outside the allowlist must fail under enforcement: {probe}\n{output}"
3639            );
3640        }
3641        assert!(
3642            !outside_file.exists(),
3643            "denied write must not create the file"
3644        );
3645        assert!(
3646            !temp_root_marker.exists(),
3647            "denied temp-root write must not create the marker"
3648        );
3649
3650        // Mission metadata: the write is denied (macOS) or evaporates into
3651        // the bwrap mask (linux) — either way the host bytes are untouched.
3652        let (ok, _) = run_shell_command_sandboxed(
3653            repo.path(),
3654            &format!(
3655                "echo tampered >> '{}'",
3656                mission.join("events.jsonl").display()
3657            ),
3658            &env,
3659            &sandbox,
3660        )
3661        .await;
3662        if cfg!(target_os = "macos") {
3663            assert!(!ok, "events.jsonl append must be denied under Seatbelt");
3664        }
3665        assert_eq!(
3666            std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
3667            "{\"seq\":1}\n",
3668            "the audit log must be untouched by the sandboxed gate"
3669        );
3670        let (ok, _) = run_shell_command_sandboxed(
3671            repo.path(),
3672            &format!(
3673                "echo x > '{}'",
3674                mission.join("control/approve.json").display()
3675            ),
3676            &env,
3677            &sandbox,
3678        )
3679        .await;
3680        if cfg!(target_os = "macos") {
3681            assert!(!ok, "control/ writes must be denied under Seatbelt");
3682        }
3683        assert!(
3684            std::fs::read_dir(mission.join("control"))
3685                .unwrap()
3686                .next()
3687                .is_none(),
3688            "the control inbox must stay empty on the host"
3689        );
3690
3691        // Authority reads fail: Seatbelt refuses the open, bwrap masks the
3692        // content — `test -s` (non-empty) fails under both, while an ordinary
3693        // repo file still reads fine.
3694        for name in ["serve.token", "serve.read.token", "config.json"] {
3695            let (ok, output) = run_shell_command_sandboxed(
3696                repo.path(),
3697                &format!("test -s '{}'", kranz_dir.join(name).display()),
3698                &env,
3699                &sandbox,
3700            )
3701            .await;
3702            assert!(
3703                !ok,
3704                "a read of denied authority path .kranz/{name} must fail: {output}"
3705            );
3706        }
3707        let (ok, output) = run_shell_command_sandboxed(
3708            repo.path(),
3709            &format!("test -s '{}'", repo.path().join("public.txt").display()),
3710            &env,
3711            &sandbox,
3712        )
3713        .await;
3714        assert!(ok, "ordinary repo reads must keep working: {output}");
3715
3716        // Anti-vacuity / the ticket's off arm: the SAME probes with
3717        // `enforce == off` succeed (the probe commands are valid; only the
3718        // profile denies them).
3719        let off_env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3720        for probe in [
3721            format!("echo x > '{}'", outside_file.display()),
3722            format!("echo x > '{}'", temp_root_marker.display()),
3723            format!(
3724                "echo tampered >> '{}'",
3725                mission.join("events.jsonl").display()
3726            ),
3727            format!("test -s '{}'", kranz_dir.join("serve.token").display()),
3728        ] {
3729            let (ok, output) =
3730                run_shell_command_sandboxed(repo.path(), &probe, &off_env, &GateSandbox::Disabled)
3731                    .await;
3732            assert!(
3733                ok,
3734                "with enforce == off the probe succeeds (today's posture): {probe}\n{output}"
3735            );
3736        }
3737        // Undo the off-arm's metadata append so the layout stays honest, and
3738        // sweep the temp-root marker.
3739        std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
3740        let _ = std::fs::remove_file(&temp_root_marker);
3741    }
3742
3743    /// The kill discipline reaches the whole tree THROUGH the wrapper: the
3744    /// sandbox wrapper (sandbox-exec/bwrap) leads the same new process group,
3745    /// so the timeout SIGKILL takes a backgrounded grandchild down with it.
3746    // await_holding_lock: see the note on
3747    // gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads.
3748    #[cfg(unix)]
3749    #[tokio::test]
3750    #[allow(clippy::await_holding_lock)]
3751    async fn gate_sandbox_wrap_timeout_kills_the_whole_process_tree() {
3752        let _guard = GATE_SANDBOX_WRAP_LOCK
3753            .lock()
3754            .unwrap_or_else(|p| p.into_inner());
3755        if !gate_wrap_enforcement_available() {
3756            return;
3757        }
3758
3759        let (repo, mission) = gate_wrap_layout();
3760        let scratch = tempfile::tempdir().unwrap();
3761        let resolution = resolve_gate_sandbox(
3762            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3763            repo.path(),
3764            &mission,
3765            scratch.path(),
3766            scratch.path(),
3767        )
3768        .unwrap();
3769        let sandbox = resolution.sandbox;
3770        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3771
3772        let pidfile = scratch.path().join("child.pid");
3773        let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
3774        #[cfg(target_os = "linux")]
3775        let namespace_file = scratch.path().join("child.pid-namespace");
3776        #[cfg(target_os = "linux")]
3777        let command = format!(
3778            "readlink /proc/self/ns/pid > '{}'; {command}",
3779            namespace_file.display()
3780        );
3781        let (code, output) = tokio::time::timeout(
3782            Duration::from_secs(15),
3783            run_shell_command_sandboxed_with_code(
3784                repo.path(),
3785                &command,
3786                Duration::from_millis(500),
3787                &env,
3788                &sandbox,
3789            ),
3790        )
3791        .await
3792        .expect("timed-out command must return promptly");
3793        assert_eq!(code, None, "a timeout yields no exit code: {output}");
3794        assert!(output.contains("timed out"), "got: {output}");
3795
3796        let pid: i32 = std::fs::read_to_string(&pidfile)
3797            .expect("the wrapped shell wrote the background pid before the timeout")
3798            .trim()
3799            .parse()
3800            .expect("pidfile contains a pid");
3801        #[cfg(target_os = "linux")]
3802        let namespace = std::fs::read_to_string(namespace_file).unwrap();
3803        let child_alive = || {
3804            #[cfg(target_os = "linux")]
3805            {
3806                // $! is namespace-local after bwrap's --unshare-pid. Inspect
3807                // the namespace from the host instead of treating that small
3808                // integer as an unrelated host PID (often PID 2).
3809                std::fs::read_dir("/proc").unwrap().flatten().any(|entry| {
3810                    std::fs::read_link(entry.path().join("ns/pid"))
3811                        .is_ok_and(|link| link.to_string_lossy() == namespace.trim())
3812                })
3813            }
3814            #[cfg(not(target_os = "linux"))]
3815            {
3816                (unsafe { libc::kill(pid, 0) }) == 0
3817            }
3818        };
3819        let deadline = std::time::Instant::now() + Duration::from_secs(5);
3820        while child_alive() {
3821            assert!(
3822                std::time::Instant::now() < deadline,
3823                "background child {pid} survived the group kill through the sandbox wrapper"
3824            );
3825            tokio::time::sleep(Duration::from_millis(50)).await;
3826        }
3827    }
3828
3829    /// The gate-SPECIFIC supervision policy, asserted end-to-end (ticket
3830    /// gate-sandbox-supervision-dogfood): the wrapped gate may signal
3831    /// processes INSIDE its own sandboxed tree (`(allow signal (target
3832    /// same-sandbox))` — see [`gate_profile_extras`]), and it gains NO
3833    /// host-wide capability. Probes against the resolved wrap:
3834    ///
3835    /// - `kill -0` + `kill -TERM` against a child the wrapped command
3836    ///   spawned itself (the engine suite's timeout-kill / liveness-poll
3837    ///   shape): ALLOWED.
3838    /// - `kill -0` against a SAME-UID host process started OUTSIDE the
3839    ///   sandbox (its pid baked into the command): DENIED.
3840    /// - `ps` inspection of that host process: DENIED — `/bin/ps` is setuid
3841    ///   root and setuid exec is kernel-denied inside ANY sandbox (probed
3842    ///   2026-08-05, not SBPL-expressible), so ps-based inspection of ANY
3843    ///   process is unreachable inside the wrap; the in-tree inspection
3844    ///   need is served by `proc_pidinfo` instead (event_log's identity
3845    ///   tokens, covered by the wrapped-suite fixture below).
3846    ///
3847    /// Anti-vacuity: with enforcement off the SAME host probes succeed, so
3848    /// the denials above are the sandbox's, not a broken probe. macOS-only:
3849    /// the policy being pinned is an SBPL clause — bwrap has no signal tier
3850    /// to scope (the host probe succeeds there by design). Under a wrapped
3851    /// `cargo test` the nested smoke-apply in
3852    /// [`gate_wrap_sandbox_exec_can_apply`] fails and this test skips
3853    /// cleanly, like every enforcement test.
3854    #[cfg(target_os = "macos")]
3855    #[tokio::test]
3856    #[allow(clippy::await_holding_lock)]
3857    async fn gate_sandbox_wrap_dogfood_supervision_allows_tree_denies_host() {
3858        let _guard = GATE_SANDBOX_WRAP_LOCK
3859            .lock()
3860            .unwrap_or_else(|p| p.into_inner());
3861        if !gate_wrap_enforcement_available() {
3862            return;
3863        }
3864
3865        let (repo, mission) = gate_wrap_layout();
3866        let scratch = tempfile::tempdir().unwrap();
3867        let resolution = resolve_gate_sandbox(
3868            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3869            repo.path(),
3870            &mission,
3871            scratch.path(),
3872            scratch.path(),
3873        )
3874        .unwrap();
3875        let sandbox = resolution.sandbox;
3876        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3877
3878        // The "unrelated host process": a same-uid sleeper spawned OUTSIDE
3879        // the wrap (never under its label), killed and reaped on scope exit.
3880        let mut host = std::process::Command::new("sleep")
3881            .arg("300")
3882            .spawn()
3883            .expect("spawn host sleeper");
3884        let host_pid = host.id();
3885
3886        // In-tree supervision works: the wrapped command spawns a child,
3887        // liveness-probes it, and kills it — the exact shape the engine
3888        // suite's timeout-kill tests need.
3889        let (ok, output) = run_shell_command_sandboxed(
3890            repo.path(),
3891            "sleep 300 & child=$!; kill -0 \"$child\" && kill -TERM \"$child\"",
3892            &env,
3893            &sandbox,
3894        )
3895        .await;
3896        assert!(
3897            ok,
3898            "the wrapped gate must signal its own tree (same-sandbox): {output}"
3899        );
3900
3901        // Host-wide supervision stays denied: signal AND ps inspection of
3902        // the outside process both fail inside the wrap.
3903        let (ok, output) = run_shell_command_sandboxed(
3904            repo.path(),
3905            &format!("kill -0 {host_pid}"),
3906            &env,
3907            &sandbox,
3908        )
3909        .await;
3910        assert!(
3911            !ok,
3912            "no host-wide signal capability under the wrap (EPERM expected): {output}"
3913        );
3914        let (ok, output) = run_shell_command_sandboxed(
3915            repo.path(),
3916            &format!("ps -p {host_pid} -o command="),
3917            &env,
3918            &sandbox,
3919        )
3920        .await;
3921        assert!(
3922            !ok,
3923            "no ps inspection under the wrap (setuid exec denied): {output}"
3924        );
3925
3926        // Anti-vacuity: the SAME host probes succeed with enforcement off —
3927        // the denials above are the sandbox's doing, not a broken probe.
3928        let (ok, output) = run_shell_command_sandboxed(
3929            repo.path(),
3930            &format!("kill -0 {host_pid} && ps -p {host_pid} -o command="),
3931            &env,
3932            &GateSandbox::Disabled,
3933        )
3934        .await;
3935        assert!(
3936            ok,
3937            "with enforce == off the host probes succeed (today's posture): {output}"
3938        );
3939
3940        let _ = host.kill();
3941        let _ = host.wait();
3942    }
3943
3944    /// THE DOGFOOD PROVING GROUND (ticket gate-sandbox-supervision-dogfood):
3945    /// this repo's mandatory merge gate — `cargo test --workspace` — run as
3946    /// a WRAPPED contract command through the real gate-wrap path
3947    /// ([`resolve_gate_sandbox`] + the bounded sandboxed runner, `enforce:
3948    /// fs`, gate cwd = the repo root). The self-referential failures the
3949    /// module doc's measurement section records must be GONE: the
3950    /// signal/liveness class is covered by the `same-sandbox` supervision
3951    /// extra, the own-pid token class by proc_pidinfo-first identity
3952    /// tokens, and the tests NO sandbox can host (setuid `/bin/ps` exec,
3953    /// nested `sandbox_apply` of a different profile — both kernel-denied,
3954    /// see [`gate_profile_extras`]) skip with the detectable
3955    /// `SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)` marker, which
3956    /// this fixture counts and reports from the captured suite log.
3957    ///
3958    /// Ignored by default — a full wrapped workspace suite is far too slow
3959    /// for the normal gate; the `rust-macos-wrapped-suite` CI job runs it
3960    /// explicitly. Run manually:
3961    ///
3962    /// ```sh
3963    /// cargo test -p kranz-engine dogfood_supervision -- --ignored --nocapture
3964    /// ```
3965    ///
3966    /// `KRANZ_DOGFOOD_SUITE_CMD` overrides the payload (scoping during
3967    /// development); the default is the ticket's gate verbatim. The
3968    /// `rust-macos-wrapped-suite` CI job overrides it to
3969    /// `cargo test --workspace -- --nocapture`: the asserted exit code is
3970    /// unchanged, but libtest then streams the suite's SKIP-UNDER-WRAP
3971    /// markers into the job log — with the default capture the markers are
3972    /// swallowed and the count below reads 0 even though the skips fired
3973    /// (verified 2026-08-05 by running the six premise-gated tests under a
3974    /// hand-built gate-shaped profile with --nocapture: every marker
3975    /// fires). The runner gets a generous wall clock rather than
3976    /// COMMAND_TIMEOUT: the production 600s contract-command cap is
3977    /// deliberately untouched, and a wrapped full-workspace suite is known
3978    /// to run past it (measured 2026-08-05 on a loaded M-series host:
3979    /// 2713s green end-to-end, most of it the in-sandbox dependency
3980    /// rebuild the cache-only CARGO_HOME forces — the same cost a
3981    /// production wrapped gate pays) — the fixture proves the SUPERVISION
3982    /// POLICY, not the production timeout budget.
3983    #[cfg(target_os = "macos")]
3984    #[test]
3985    #[ignore = "wrapped-suite proving ground — run manually or via the rust-macos-wrapped-suite CI job"]
3986    fn gate_sandbox_wrap_dogfood_supervision_workspace_suite() {
3987        if !gate_wrap_sandbox_exec_can_apply() {
3988            return;
3989        }
3990        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3991            .parent()
3992            .and_then(std::path::Path::parent)
3993            .expect("crates/engine has a repo-root ancestor")
3994            .to_path_buf();
3995        let payload = std::env::var("KRANZ_DOGFOOD_SUITE_CMD")
3996            .unwrap_or_else(|_| "cargo test --workspace".to_string());
3997
3998        // Mirror run_bounded_gate_command_sandboxed's setup (per-run
3999        // scratch, TMPDIR redirect, cache-only Cargo home inside it) so the
4000        // wrap the suite runs under IS the production merge-gate wrap; only
4001        // the wall clock differs (see the doc above). The suite log lands
4002        // in the scratch via a plain redirect — never a pipe, so the bare
4003        // cargo exit code is what gets asserted.
4004        let scratch =
4005            std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
4006        std::fs::create_dir_all(scratch.join("tmp")).unwrap();
4007        let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
4008        assert!(
4009            cargo_home.is_dir(),
4010            "could not create the fixture's cache-only Cargo home at {}",
4011            cargo_home.display()
4012        );
4013        // The fake mission layout only feeds the deny computation — nothing
4014        // real is touched; the repo root is the writable gate cwd.
4015        let (_layout_guard, mission) = gate_wrap_layout();
4016        let resolution = resolve_gate_sandbox(
4017            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4018            &repo_root,
4019            &mission,
4020            &scratch,
4021            &scratch,
4022        )
4023        .expect("the fixture's gate sandbox resolves on a host that applied the smoke profile");
4024        let mut env = sanitized_gate_env();
4025        env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
4026        for var in ["TMPDIR", "TMP", "TEMP"] {
4027            env.insert(var.to_string(), scratch.join("tmp").display().to_string());
4028        }
4029        // The wrapped suite creates missions, and a mission acquires its
4030        // event log only with the repository authority key. The gate profile
4031        // denies the operator's real key directory (that is the point of the
4032        // deny), so the inner suite gets its own global kranz dir under the
4033        // writable scratch via `KRANZ_HOME`. A real gate command never needs
4034        // a key and never gets this override.
4035        let kranz_home = scratch.join("kranz-home");
4036        std::fs::create_dir_all(&kranz_home).unwrap();
4037        env.insert("KRANZ_HOME".to_string(), kranz_home.display().to_string());
4038        let suite_log = scratch.join("tmp").join("dogfood-suite.log");
4039        let command = format!("{payload} > '{}' 2>&1", suite_log.display());
4040
4041        let runtime = tokio::runtime::Builder::new_current_thread()
4042            .enable_all()
4043            .build()
4044            .expect("fixture runtime");
4045        let start = std::time::Instant::now();
4046        let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
4047            &repo_root,
4048            &command,
4049            Duration::from_secs(3600),
4050            &env,
4051            &resolution.sandbox,
4052        ));
4053        let elapsed = start.elapsed();
4054
4055        let log = std::fs::read_to_string(&suite_log)
4056            .unwrap_or_else(|_| format!("<no suite log captured; runner tail: {output}>"));
4057        let skip_count = log
4058            .matches("SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)")
4059            .count();
4060        println!(
4061            "dogfood wrapped suite `{payload}`: exit={code:?} elapsed={elapsed:.1?} \
4062             skip-under-wrap markers={skip_count} log={}",
4063            suite_log.display()
4064        );
4065        for line in log.lines().filter(|l| l.contains("test result:")) {
4066            println!("  {line}");
4067        }
4068        // On failure the assert MUST carry the log tail — CI runners are
4069        // ephemeral and the scratch path alone is no evidence (the c6845f7
4070        // rust-macos-wrapped-suite failure gave an untailorable exit 101).
4071        let tail: Vec<&str> = log.lines().collect();
4072        let tail = &tail[tail.len().saturating_sub(40)..];
4073        assert_eq!(
4074            code,
4075            Some(0),
4076            "cargo test --workspace must run GREEN as a wrapped contract command \
4077             (skip-under-wrap markers seen: {skip_count})\n--- suite log tail ---\n{}",
4078            tail.join("\n")
4079        );
4080        // Cleanup only on success: on failure the assert above has already
4081        // panicked with the log's path, and the scratch (suite log, profile,
4082        // scratch home) survives for post-mortem debugging — the same
4083        // self-cleaning shape as the production path, minus the
4084        // evidence-destroying failure case.
4085        let _ = std::fs::remove_dir_all(&scratch);
4086    }
4087
4088    /// The merge-gate HOME question (ticket open work), settled by evidence:
4089    /// under the profile the ambient-HOME pass-through is KEPT and the
4090    /// profile makes the real home READ-ONLY — `git config user.name` still
4091    /// resolves from `~/.gitconfig` while `touch $HOME/...` is denied. Also
4092    /// pinned: TMPDIR redirects into the per-run `kranz-gate-*` scratch (the
4093    /// ambient temp root is not writable) and the cache-only Cargo home
4094    /// lives inside that same scratch.
4095    #[cfg(unix)]
4096    #[test]
4097    fn gate_sandbox_wrap_merge_gate_reads_git_identity_from_read_only_home() {
4098        let _wrap_guard = GATE_SANDBOX_WRAP_LOCK
4099            .lock()
4100            .unwrap_or_else(|p| p.into_inner());
4101        if !gate_wrap_enforcement_available() {
4102            return;
4103        }
4104
4105        let (repo, mission) = gate_wrap_layout();
4106        let fake_home = tempfile::tempdir().unwrap();
4107        std::fs::write(
4108            fake_home.path().join(".gitconfig"),
4109            "[user]\n\tname = Gate Wrap Test\n",
4110        )
4111        .unwrap();
4112        let _home = crate::agent_env::EnvTestGuard::engage(&[(
4113            "HOME",
4114            fake_home.path().to_str().expect("utf-8 temp path"),
4115        )]);
4116        let policy = MergeGatePolicy {
4117            sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4118            mission_dir: mission.clone(),
4119        };
4120        assert!(policy.enforces_on_this_host());
4121
4122        let (ok, output) = run_bounded_gate_command_sandboxed(
4123            repo.path(),
4124            "test \"$(git config user.name)\" = 'Gate Wrap Test' \
4125             && ! touch \"$HOME/gate_sandbox_wrap_marker\" \
4126             && case \"$TMPDIR\" in *kranz-gate-*/tmp) true ;; *) false ;; esac \
4127             && case \"$CARGO_HOME\" in *kranz-gate-*/.cargo-cache-only-*) true ;; *) false ;; esac",
4128            &policy,
4129        );
4130        assert!(
4131            ok,
4132            "git identity must read from the read-only HOME, $HOME writes must be \
4133             denied, and TMPDIR/CARGO_HOME must sit in the per-run scratch: {output}"
4134        );
4135        assert!(
4136            !fake_home.path().join("gate_sandbox_wrap_marker").exists(),
4137            "the denied $HOME write must not have created the marker"
4138        );
4139
4140        // Anti-vacuity: the same $HOME write succeeds with enforcement off.
4141        let (ok, output) = run_bounded_gate_command_sandboxed(
4142            repo.path(),
4143            "touch \"$HOME/gate_sandbox_wrap_off_marker\"",
4144            &MergeGatePolicy::disabled(),
4145        );
4146        assert!(
4147            ok,
4148            "with enforce == off the $HOME write succeeds (today's posture): {output}"
4149        );
4150        let _ = std::fs::remove_file(fake_home.path().join("gate_sandbox_wrap_off_marker"));
4151    }
4152
4153    /// 13th-pass review (P1), the gate half of the shared-Cargo-cache deny:
4154    /// a wrapped gate READS the operator's real registry/git cache (the
4155    /// link target the over-ceiling isolated home points at — the read is
4156    /// the cache's whole purpose) but cannot WRITE it: the profile's
4157    /// explicit cache write deny holds regardless of the gate's writable
4158    /// roots. `enforce == off` keeps the documented trade (the write
4159    /// succeeds) — the anti-vacuity arm.
4160    // await_holding_lock: see the note on
4161    // gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads.
4162    #[cfg(unix)]
4163    #[tokio::test]
4164    #[allow(clippy::await_holding_lock)]
4165    async fn gate_sandbox_wrap_cache_write_deny_reads_cache_but_cannot_write() {
4166        let _guard = GATE_SANDBOX_WRAP_LOCK
4167            .lock()
4168            .unwrap_or_else(|p| p.into_inner());
4169        if !gate_wrap_enforcement_available() {
4170            return;
4171        }
4172
4173        let (repo, mission) = gate_wrap_layout();
4174        let scratch = tempfile::tempdir().unwrap();
4175        // The "operator's" shared cache, armed via CARGO_HOME so BOTH the
4176        // profile deny computation and the cache-only home seeding resolve
4177        // it (the same paths cache_only_cargo_home links).
4178        let cargo = tempfile::tempdir().unwrap();
4179        std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
4180        std::fs::write(cargo.path().join("registry/cache-marker"), "cached").unwrap();
4181        let _cargo = crate::agent_env::EnvTestGuard::engage(&[(
4182            "CARGO_HOME",
4183            cargo.path().to_str().expect("utf-8 temp path"),
4184        )]);
4185
4186        let resolution = resolve_gate_sandbox(
4187            &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4188            repo.path(),
4189            &mission,
4190            scratch.path(),
4191            scratch.path(),
4192        )
4193        .unwrap();
4194        let sandbox = resolution.sandbox;
4195        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4196
4197        // The cache READS fine (broad read allow / ro-bind)…
4198        let (ok, output) = run_shell_command_sandboxed(
4199            repo.path(),
4200            &format!(
4201                "test -s '{}'",
4202                cargo.path().join("registry/cache-marker").display()
4203            ),
4204            &env,
4205            &sandbox,
4206        )
4207        .await;
4208        assert!(ok, "the wrapped gate must read the shared cache: {output}");
4209
4210        // …but a WRITE to the real cache dir is denied, and the bytes stay
4211        // off the host either way (Seatbelt denies; bwrap's stacked ro-bind
4212        // refuses).
4213        let poison = cargo.path().join("registry/poisoned-crate");
4214        let (ok, output) = run_shell_command_sandboxed(
4215            repo.path(),
4216            &format!("echo x > '{}'", poison.display()),
4217            &env,
4218            &sandbox,
4219        )
4220        .await;
4221        assert!(
4222            !ok,
4223            "a write to the operator's real cargo cache must fail under enforcement: {output}"
4224        );
4225        assert!(
4226            !poison.exists(),
4227            "the denied cache write must not create the file"
4228        );
4229
4230        // Anti-vacuity: the SAME write succeeds with enforcement off (the
4231        // documented trade the operator opts into with enforce: off).
4232        let (ok, output) = run_shell_command_sandboxed(
4233            repo.path(),
4234            &format!("echo x > '{}'", poison.display()),
4235            &env,
4236            &GateSandbox::Disabled,
4237        )
4238        .await;
4239        assert!(
4240            ok,
4241            "with enforce == off the cache write succeeds (documented trade): {output}"
4242        );
4243        let _ = std::fs::remove_file(&poison);
4244    }
4245
4246    /// The merge-gate off regression: a disabled policy delegates to the
4247    /// pre-wrap executor, so the gate shape is today's exactly — the
4248    /// cache-only Cargo home directly under the system temp root and the
4249    /// ambient TMPDIR untouched.
4250    #[cfg(unix)]
4251    #[test]
4252    fn gate_sandbox_wrap_disabled_merge_policy_matches_todays_gate_shape() {
4253        let dir = tempfile::tempdir().unwrap();
4254        // `dirname` normalizes the trailing slash macOS puts on $TMPDIR (and
4255        // therefore on std::env::temp_dir()); trim it for the comparison.
4256        let temp = std::env::temp_dir().display().to_string();
4257        let temp = temp.trim_end_matches('/');
4258        let ambient_tmpdir = std::env::var("TMPDIR").unwrap_or_else(|_| "unset".to_string());
4259        let command = format!(
4260            "test \"$(dirname \"$CARGO_HOME\")\" = '{temp}' \
4261             && test \"${{TMPDIR:-unset}}\" = '{ambient_tmpdir}'"
4262        );
4263        let (ok, output) =
4264            run_bounded_gate_command_sandboxed(dir.path(), &command, &MergeGatePolicy::disabled());
4265        assert!(
4266            ok,
4267            "the off path must keep today's gate shape (cache-only home under the \
4268             system temp root, ambient TMPDIR): {output}"
4269        );
4270    }
4271
4272    /// Under `fs+net` the wrapped gate is offline-by-cache:
4273    /// `CARGO_NET_OFFLINE=true` is injected so a cold cache fails with a
4274    /// clear cargo error instead of a kernel-denied socket (no egress proxy
4275    /// is wired for engine-side gates — see the module doc). `fs` and the
4276    /// Disabled posture leave the env untouched.
4277    #[test]
4278    fn gate_sandbox_wrap_fs_net_forces_cargo_offline() {
4279        let base: HashMap<String, String> = HashMap::new();
4280        let fs_net = GateSandbox::Seatbelt {
4281            enforce: crate::types::SandboxEnforce::FsNet,
4282            profile_path: std::path::PathBuf::from("/nonexistent"),
4283        };
4284        let env = gate_env_for_sandbox(&base, &fs_net);
4285        assert_eq!(
4286            env.get("CARGO_NET_OFFLINE").map(String::as_str),
4287            Some("true"),
4288            "fs+net gates run cargo offline-by-cache"
4289        );
4290        let fs = GateSandbox::Seatbelt {
4291            enforce: crate::types::SandboxEnforce::Fs,
4292            profile_path: std::path::PathBuf::from("/nonexistent"),
4293        };
4294        assert!(
4295            !gate_env_for_sandbox(&base, &fs).contains_key("CARGO_NET_OFFLINE"),
4296            "fs keeps full egress — no offline flag"
4297        );
4298        assert!(
4299            !gate_env_for_sandbox(&base, &GateSandbox::Disabled).contains_key("CARGO_NET_OFFLINE"),
4300            "the off path is byte-identical — no offline flag"
4301        );
4302        // The container arm keys off the same `enforce()`: fs+net inside the
4303        // mission container is `--network none`, so cargo must run
4304        // offline-by-cache there too.
4305        let container_fs_net = GateSandbox::Container {
4306            inputs: Box::new(crate::sandbox::SandboxInputs {
4307                enforce: crate::types::SandboxEnforce::FsNet,
4308                session_cwd: std::path::PathBuf::from("/nonexistent"),
4309                mission_dir: std::path::PathBuf::from("/nonexistent"),
4310                tmpdir: std::path::PathBuf::from("/nonexistent"),
4311                extra_write: Vec::new(),
4312                egress: Vec::new(),
4313                validator_read_deny_roots: Vec::new(),
4314            }),
4315            spec: crate::sandbox_container::ContainerSpec {
4316                runtime: crate::sandbox_container::ContainerRuntime::Docker,
4317                image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4318                network: None,
4319                name: None,
4320            },
4321        };
4322        assert_eq!(
4323            gate_env_for_sandbox(&base, &container_fs_net)
4324                .get("CARGO_NET_OFFLINE")
4325                .map(String::as_str),
4326            Some("true"),
4327            "fs+net container gates run cargo offline-by-cache"
4328        );
4329        assert!(
4330            !base.contains_key("CARGO_NET_OFFLINE"),
4331            "the caller's env map is never mutated"
4332        );
4333    }
4334
4335    /// The container arm's per-command wrap shape (ticket
4336    /// container-gate-wrapper): the runtime binary is the program, the argv
4337    /// names a UNIQUE per-command container (`kranz-gate-*`) and carries the
4338    /// command as the image's `sh -c` payload, and the timeout teardown is
4339    /// `<runtime> rm -f <name>` targeting exactly that container (the
4340    /// bounded core's group SIGKILL stops the runtime client; the teardown
4341    /// stops the daemon-owned in-container tree). The process-sandbox arms
4342    /// have NO teardown — the group kill IS the tree kill there.
4343    #[test]
4344    fn container_gate_wrap_shell_shape_names_the_container_and_teardown() {
4345        let inputs = crate::sandbox::SandboxInputs {
4346            enforce: crate::types::SandboxEnforce::Fs,
4347            session_cwd: std::path::PathBuf::from("/nonexistent"),
4348            mission_dir: std::path::PathBuf::from("/nonexistent-m"),
4349            tmpdir: std::path::PathBuf::from("/nonexistent-s"),
4350            extra_write: Vec::new(),
4351            egress: Vec::new(),
4352            validator_read_deny_roots: Vec::new(),
4353        };
4354        let container = GateSandbox::Container {
4355            inputs: Box::new(inputs),
4356            spec: crate::sandbox_container::ContainerSpec {
4357                runtime: crate::sandbox_container::ContainerRuntime::Docker,
4358                image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4359                network: None,
4360                name: None,
4361            },
4362        };
4363        let env: HashMap<String, String> = [("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string())]
4364            .into_iter()
4365            .collect();
4366
4367        let one = container.wrap_shell("echo hi", &env).unwrap();
4368        let two = container.wrap_shell("echo hi", &env).unwrap();
4369        assert_eq!(one.program, std::path::PathBuf::from("docker"));
4370        let name_of = |wrapped: &WrappedCommand| {
4371            wrapped
4372                .args
4373                .windows(2)
4374                .find(|w| w[0] == "--name")
4375                .map(|w| w[1].clone())
4376                .expect("the container argv must name its container")
4377        };
4378        let (name_one, name_two) = (name_of(&one), name_of(&two));
4379        assert!(
4380            name_one.starts_with("kranz-gate-"),
4381            "gate containers carry the kranz-gate- prefix: {name_one}"
4382        );
4383        assert_ne!(
4384            name_one, name_two,
4385            "container names are per command, never per resolve — parallel \
4386             gate commands from one resolution must not collide"
4387        );
4388        assert_eq!(
4389            one.timeout_teardown,
4390            Some((
4391                std::path::PathBuf::from("docker"),
4392                vec!["rm".to_string(), "-f".to_string(), name_one]
4393            )),
4394            "the teardown force-removes exactly this command's container"
4395        );
4396        assert!(
4397            one.args.ends_with(&[
4398                crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4399                "sh".to_string(),
4400                "-c".to_string(),
4401                "echo hi".to_string()
4402            ]),
4403            "image then sh -c payload: {:?}",
4404            one.args
4405        );
4406
4407        // The process-sandbox arms and Disabled carry no teardown.
4408        let seatbelt = GateSandbox::Seatbelt {
4409            enforce: crate::types::SandboxEnforce::Fs,
4410            profile_path: std::path::PathBuf::from("/nonexistent"),
4411        };
4412        assert!(seatbelt
4413            .wrap_shell("true", &env)
4414            .unwrap()
4415            .timeout_teardown
4416            .is_none());
4417        assert!(GateSandbox::Disabled
4418            .wrap_shell("true", &env)
4419            .unwrap()
4420            .timeout_teardown
4421            .is_none());
4422    }
4423
4424    /// The ticket's core test gate: with provider:container + enforce != off
4425    /// a contract command provably executes INSIDE the mission container —
4426    /// reads/writes on the mount set work (the gate cwd write lands on the
4427    /// host, the scratch is writable via $HOME, `KRANZ_BASE_SHA` crosses via
4428    /// the forwarded `-e`), writes OUTSIDE the mount set fail (`/etc` on the
4429    /// read-only rootfs, and a sibling host temp dir the container never
4430    /// mounts), the mission metadata mount is read-only (the events.jsonl
4431    /// append fails and the host bytes are untouched), and the host's
4432    /// `.kranz/serve.token` is unreachable (its /dev/null mask reads back
4433    /// empty, so `test -s` fails). The off arm (GateSandbox::Disabled on the
4434    /// host) proves the probes are valid — the SAME probes succeed there, so
4435    /// the container is what denies them.
4436    ///
4437    /// Skips outside the live-proven Linux host path or without a runtime;
4438    /// CI ubuntu-latest has Docker. Unix-only: the probes are POSIX
4439    /// shell inside the container and POSIX tempfile paths on the host. No
4440    /// GATE_SANDBOX_WRAP_LOCK: that lock serializes sandbox-exec/bwrap spawn
4441    /// contention, and this test spawns only the container runtime.
4442    #[cfg(unix)]
4443    #[tokio::test]
4444    #[allow(clippy::await_holding_lock)]
4445    async fn container_gate_wrap_runs_contract_command_inside_the_container() {
4446        let _env = crate::agent_env::EnvTestGuard::engage(&[]);
4447        if !crate::sandbox_container::host_supports_container_contract() {
4448            crate::test_capability::skip(
4449                crate::test_capability::capability::CONTAINER,
4450                &crate::sandbox_container::container_contract_skip_detail(),
4451            );
4452            return;
4453        }
4454        if crate::sandbox_container::detect().is_none() {
4455            eprintln!(
4456                "no container runtime (docker/podman/nerdctl/container) on PATH; skipping \
4457                 container gate wrap fixture"
4458            );
4459            return;
4460        }
4461
4462        // Only live container fixtures need a VM-shared path. Native gate
4463        // fixtures stay outside the checkout so Git cannot discover its config.
4464        let (repo, mission) = gate_wrap_layout_with_repo(
4465            tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(),
4466        );
4467        let kranz_dir = repo.path().join(".kranz");
4468        let scratch = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
4469        let outside = tempfile::tempdir().unwrap();
4470        let container_cfg = crate::types::SandboxConfig {
4471            enforce: crate::types::SandboxEnforce::Fs,
4472            provider: crate::types::SandboxProvider::Container,
4473            image: None,
4474            extra_write: vec![],
4475            egress: vec![],
4476        };
4477        let resolution = resolve_gate_sandbox(
4478            &container_cfg,
4479            repo.path(),
4480            &mission,
4481            scratch.path(),
4482            scratch.path(),
4483        )
4484        .unwrap();
4485        assert!(resolution.note.is_none());
4486        let sandbox = resolution.sandbox;
4487        assert!(
4488            matches!(sandbox, GateSandbox::Container { .. }),
4489            "provider:container with a runtime must resolve to the container wrap"
4490        );
4491        let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
4492
4493        // Reads/writes on the mount set work: the gate cwd write lands on
4494        // the host, the scratch (HOME) is writable, and the contract env
4495        // crossed into the container.
4496        let ok_file = repo.path().join("container_gate_wrap_ok.txt");
4497        let (ok, output) = run_shell_command_sandboxed(
4498            repo.path(),
4499            &format!(
4500                "echo ok > '{}' && echo scratch > \"$HOME/container_gate_wrap_scratch.txt\" \
4501                 && test \"$KRANZ_BASE_SHA\" = deadbeef",
4502                ok_file.display()
4503            ),
4504            &env,
4505            &sandbox,
4506        )
4507        .await;
4508        assert!(
4509            ok && ok_file.exists()
4510                && scratch
4511                    .path()
4512                    .join("container_gate_wrap_scratch.txt")
4513                    .exists(),
4514            "writes inside the mount set and the forwarded env must work: {output}"
4515        );
4516
4517        // Writes OUTSIDE the mount set fail: /etc (read-only rootfs) and a
4518        // sibling host temp dir the container never mounts.
4519        let outside_file = outside.path().join("container_gate_wrap_marker");
4520        for probe in [
4521            "echo nope > /etc/container_gate_wrap_nope".to_string(),
4522            format!("echo x > '{}'", outside_file.display()),
4523        ] {
4524            let (ok, output) =
4525                run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
4526            assert!(
4527                !ok,
4528                "write outside the mount set must fail inside the container: {probe}\n{output}"
4529            );
4530        }
4531        assert!(
4532            !outside_file.exists(),
4533            "the denied write must not create the host file"
4534        );
4535
4536        // Mission metadata is read-only: the append fails and the audit log
4537        // keeps its host bytes.
4538        let (ok, _) = run_shell_command_sandboxed(
4539            repo.path(),
4540            &format!(
4541                "echo tampered >> '{}'",
4542                mission.join("events.jsonl").display()
4543            ),
4544            &env,
4545            &sandbox,
4546        )
4547        .await;
4548        assert!(!ok, "the events.jsonl append must fail on the ro mount");
4549        assert_eq!(
4550            std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
4551            "{\"seq\":1}\n",
4552            "the audit log must be untouched by the container gate"
4553        );
4554
4555        // The host's authority material is unreachable: the /dev/null mask
4556        // reads back EMPTY (test -s fails) while an ordinary repo file still
4557        // reads fine.
4558        for name in ["serve.token", "serve.read.token", "config.json"] {
4559            let (ok, output) = run_shell_command_sandboxed(
4560                repo.path(),
4561                &format!("test -s '{}'", kranz_dir.join(name).display()),
4562                &env,
4563                &sandbox,
4564            )
4565            .await;
4566            assert!(
4567                !ok,
4568                ".kranz/{name} must be /dev/null-masked inside the container: {output}"
4569            );
4570        }
4571        let (ok, output) = run_shell_command_sandboxed(
4572            repo.path(),
4573            &format!("test -s '{}'", repo.path().join("public.txt").display()),
4574            &env,
4575            &sandbox,
4576        )
4577        .await;
4578        assert!(ok, "ordinary repo reads must keep working: {output}");
4579
4580        // Anti-vacuity: the SAME probes succeed with enforcement off (the
4581        // probe commands are valid; only the container denies them).
4582        let (ok, output) = run_shell_command_sandboxed(
4583            repo.path(),
4584            &format!(
4585                "echo x > '{}' && test -s '{}'",
4586                outside_file.display(),
4587                kranz_dir.join("serve.token").display()
4588            ),
4589            &env,
4590            &GateSandbox::Disabled,
4591        )
4592        .await;
4593        assert!(
4594            ok,
4595            "with enforce == off the probes succeed (today's posture): {output}"
4596        );
4597        let _ = std::fs::remove_file(&outside_file);
4598    }
4599
4600    /// Real-host M7 receipt for Linux. The dedicated CI invocation installs
4601    /// bubblewrap, then runs this exact ignored test with `--nocapture` so the
4602    /// retained timing and containment evidence is visible in the job log.
4603    #[cfg(target_os = "linux")]
4604    #[tokio::test]
4605    #[ignore = "live bubblewrap receipt — run by the protected Linux CI leg"]
4606    #[allow(clippy::await_holding_lock)]
4607    async fn linux_bubblewrap_hostile_live_receipt() {
4608        let _guard = GATE_SANDBOX_WRAP_LOCK
4609            .lock()
4610            .unwrap_or_else(|poisoned| poisoned.into_inner());
4611        assert!(
4612            gate_wrap_bwrap_can_apply(),
4613            "the live-proof host must provide a working bubblewrap boundary"
4614        );
4615
4616        let primary = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4617            .parent()
4618            .and_then(std::path::Path::parent)
4619            .expect("crates/engine has a repository root");
4620        let git = |args: &[&str]| {
4621            let output = std::process::Command::new("git")
4622                .args(args)
4623                .current_dir(primary)
4624                .output()
4625                .expect("git must run on the live-proof checkout");
4626            assert!(output.status.success(), "git {args:?} failed");
4627            String::from_utf8_lossy(&output.stdout).trim().to_string()
4628        };
4629        let head_before = git(&["rev-parse", "HEAD"]);
4630        let status_before = git(&["status", "--porcelain", "--untracked-files=no"]);
4631        assert!(
4632            status_before.is_empty(),
4633            "the live proof requires a clean tracked primary checkout: {status_before}"
4634        );
4635
4636        let (repo, mission) = gate_wrap_layout();
4637        let scratch = tempfile::tempdir().expect("private proof scratch");
4638        let outside = tempfile::tempdir().expect("sibling canary root");
4639        let resolution = resolve_gate_sandbox(
4640            &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
4641            repo.path(),
4642            &mission,
4643            scratch.path(),
4644            scratch.path(),
4645        )
4646        .expect("fs+net must resolve to bubblewrap on the proof host");
4647        assert!(resolution.note.is_none());
4648        assert!(matches!(resolution.sandbox, GateSandbox::Bubblewrap { .. }));
4649        let sandbox = resolution.sandbox;
4650        let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4651
4652        let canary = outside.path().join("kranz-linux-hostile-canary");
4653        let (write_ok, write_output) = run_shell_command_sandboxed(
4654            repo.path(),
4655            &format!("printf escaped > '{}'", canary.display()),
4656            &env,
4657            &sandbox,
4658        )
4659        .await;
4660        assert!(
4661            !write_ok,
4662            "sibling write escaped bubblewrap: {write_output}"
4663        );
4664        assert!(
4665            !canary.exists(),
4666            "the denied sibling canary must stay absent"
4667        );
4668
4669        let listener =
4670            std::net::TcpListener::bind("127.0.0.1:0").expect("host loopback proof listener");
4671        listener
4672            .set_nonblocking(true)
4673            .expect("nonblocking proof listener");
4674        let port = listener.local_addr().expect("listener address").port();
4675        let (stop_tx, stop_rx) = std::sync::mpsc::channel();
4676        let acceptor = std::thread::spawn(move || {
4677            let started = std::time::Instant::now();
4678            let mut accepted = 0usize;
4679            while started.elapsed() < Duration::from_secs(10) {
4680                match listener.accept() {
4681                    Ok(_) => accepted += 1,
4682                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
4683                    Err(error) => panic!("proof listener failed: {error}"),
4684                }
4685                if stop_rx.try_recv().is_ok() {
4686                    break;
4687                }
4688                std::thread::sleep(Duration::from_millis(10));
4689            }
4690            accepted
4691        });
4692        let connect = format!(
4693            "python3 -c 'import socket; socket.create_connection((\"127.0.0.1\", {port}), 2).close()'"
4694        );
4695        let (off_connect_ok, off_connect_output) =
4696            run_shell_command_sandboxed(repo.path(), &connect, &env, &GateSandbox::Disabled).await;
4697        assert!(
4698            off_connect_ok,
4699            "the network anti-vacuity probe must reach the host listener without enforcement: {off_connect_output}"
4700        );
4701        let (wrapped_connect_ok, wrapped_connect_output) =
4702            run_shell_command_sandboxed(repo.path(), &connect, &env, &sandbox).await;
4703        assert!(
4704            !wrapped_connect_ok,
4705            "the fs+net namespace reached the host listener: {wrapped_connect_output}"
4706        );
4707        let _ = stop_tx.send(());
4708        assert_eq!(
4709            acceptor.join().expect("proof listener thread"),
4710            1,
4711            "only the unwrapped anti-vacuity connection may reach the host"
4712        );
4713
4714        let gate = "node -e \"let n=0; for(let i=0;i<100000;i++)n=(n+i)>>>0; if(n!==704982704)process.exit(2); setTimeout(()=>console.log('kranz-linux-node-ok'),750)\"";
4715        for (label, posture) in [
4716            ("unwrapped warm-up", &GateSandbox::Disabled),
4717            ("bubblewrap warm-up", &sandbox),
4718        ] {
4719            let (ok, output) = run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4720            assert!(
4721                ok && output.contains("kranz-linux-node-ok"),
4722                "{label} failed: {output}"
4723            );
4724        }
4725
4726        let mut off_samples_ms = Vec::with_capacity(7);
4727        let mut wrapped_samples_ms = Vec::with_capacity(7);
4728        for index in 0..7 {
4729            for wrapped in [index % 2 == 1, index % 2 == 0] {
4730                let started = std::time::Instant::now();
4731                let posture = if wrapped {
4732                    &sandbox
4733                } else {
4734                    &GateSandbox::Disabled
4735                };
4736                let (ok, output) =
4737                    run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4738                assert!(
4739                    ok && output.contains("kranz-linux-node-ok"),
4740                    "timed gate failed: {output}"
4741                );
4742                let elapsed = started.elapsed().as_secs_f64() * 1_000.0;
4743                if wrapped {
4744                    wrapped_samples_ms.push(elapsed);
4745                } else {
4746                    off_samples_ms.push(elapsed);
4747                }
4748            }
4749        }
4750        let median = |samples: &[f64]| {
4751            let mut sorted = samples.to_vec();
4752            sorted.sort_by(f64::total_cmp);
4753            sorted[sorted.len() / 2]
4754        };
4755        let off_median_ms = median(&off_samples_ms);
4756        let wrapped_median_ms = median(&wrapped_samples_ms);
4757        let overhead_percent = (wrapped_median_ms / off_median_ms - 1.0) * 100.0;
4758
4759        let head_after = git(&["rev-parse", "HEAD"]);
4760        let status_after = git(&["status", "--porcelain", "--untracked-files=no"]);
4761        assert_eq!(head_after, head_before, "the primary checkout HEAD moved");
4762        assert_eq!(
4763            status_after, status_before,
4764            "the primary checkout's tracked bytes changed"
4765        );
4766
4767        let host = |program: &str, args: &[&str]| {
4768            std::process::Command::new(program)
4769                .args(args)
4770                .output()
4771                .ok()
4772                .filter(|output| output.status.success())
4773                .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
4774                .unwrap_or_else(|| "unavailable".to_string())
4775        };
4776        let receipt = serde_json::json!({
4777            "hostOs": std::env::consts::OS,
4778            "hostArch": std::env::consts::ARCH,
4779            "kernel": host("uname", &["-sr"]),
4780            "bubblewrap": host("bwrap", &["--version"]),
4781            "node": host("node", &["--version"]),
4782            "enforcement": "fs+net",
4783            "provider": "process/bubblewrap",
4784            "siblingWriteDenied": !write_ok && !canary.exists(),
4785            "networkDenied": !wrapped_connect_ok,
4786            "networkAntiVacuityPassed": off_connect_ok,
4787            "normalGatePassed": true,
4788            "primaryCheckoutUntouched": head_after == head_before && status_after == status_before,
4789            "repetitions": 7,
4790            "offSamplesMs": off_samples_ms,
4791            "bubblewrapSamplesMs": wrapped_samples_ms,
4792            "offMedianMs": off_median_ms,
4793            "bubblewrapMedianMs": wrapped_median_ms,
4794            "overheadPercent": overhead_percent,
4795            "overheadTargetPercent": 10.0,
4796            "withinTarget": overhead_percent <= 10.0,
4797            "head": head_before,
4798        });
4799        println!("KRANZ_LINUX_LIVE_RECEIPT={receipt}");
4800    }
4801
4802    /// MEASUREMENT HARNESS, not a CI gate (ticket
4803    /// engine-gates-sandbox-wrapped named a >~20% overhead as the opt-in
4804    /// threshold): times a real gate command through the merge-gate path,
4805    /// wrapped vs unwrapped, plus a `true` micro-benchmark isolating the
4806    /// per-spawn cost. Run manually:
4807    ///
4808    /// ```sh
4809    /// cargo test -p kranz-engine gate_sandbox_wrap_measure -- --ignored --nocapture
4810    /// KRANZ_GATE_MEASURE_CMD='cargo test --workspace' \
4811    ///   KRANZ_GATE_MEASURE_REPS=1 \
4812    ///   cargo test -p kranz-engine gate_sandbox_wrap_measure -- --ignored --nocapture
4813    /// ```
4814    ///
4815    /// The default payload skips the engine's own sandbox-hostile tests
4816    /// (probed 2026-08-03 under the wrap: 697 passed, 11 failed — every one
4817    /// of them a test of the sandbox/kill machinery itself): cross-process
4818    /// SIGKILL/`kill(pid,0)` liveness probes were EPERM under the session
4819    /// profile's `(allow signal (target self))` (the engine's own timeout
4820    /// kill is unaffected — it signals from OUTSIDE the sandbox), `ps`-based
4821    /// process identity likewise, and `sandbox_apply` from inside a sandbox
4822    /// is denied. Those 11 are the repro set of ticket
4823    /// gate-sandbox-supervision-dogfood: the signal/liveness class is now
4824    /// covered by the gate-specific `(allow signal (target same-sandbox))`
4825    /// extra, the own-pid token class by proc_pidinfo-first identity
4826    /// tokens, and the classes no sandbox can host (setuid `/bin/ps` exec,
4827    /// nested `sandbox_apply`) skip under the wrap with a detectable marker
4828    /// — see the module doc's supervision section and the
4829    /// `gate_sandbox_wrap_dogfood_supervision_*` fixtures. The skips below
4830    /// stay in this MEASUREMENT payload so the overhead number is not
4831    /// polluted by the slow self-referential tests; the wrapped-suite
4832    /// fixture (not this harness) is the green-gate proof.
4833    #[cfg(target_os = "macos")]
4834    #[test]
4835    #[ignore = "measurement harness — run manually, never a CI gate"]
4836    fn gate_sandbox_wrap_measure() {
4837        if !gate_wrap_sandbox_exec_can_apply() {
4838            return;
4839        }
4840        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4841            .parent()
4842            .and_then(std::path::Path::parent)
4843            .expect("crates/engine has a repo-root ancestor")
4844            .to_path_buf();
4845        let payload = std::env::var("KRANZ_GATE_MEASURE_CMD").unwrap_or_else(|_| {
4846            "cargo test -p kranz-engine --lib -- \
4847             --skip timeout_kills \
4848             --skip kills_a_hung_binary \
4849             --skip approval_lint_runner_times_out_slow_command \
4850             --skip identity_token \
4851             --skip pid_reuse \
4852             --skip pool_checkpoint_hooks_disabled_against_planted_fsmonitor_and_hook \
4853             --skip sandbox_preflight_probes_disposable_worktree_not_primary"
4854                .to_string()
4855        });
4856        let reps: u32 = std::env::var("KRANZ_GATE_MEASURE_REPS")
4857            .ok()
4858            .and_then(|v| v.parse().ok())
4859            .unwrap_or(3);
4860        // The fake mission layout only feeds the deny computation — nothing
4861        // real is touched; the gate cwd (the repo root) is the writable root.
4862        let (_layout_guard, mission) = gate_wrap_layout();
4863        let policy = MergeGatePolicy {
4864            sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4865            mission_dir: mission,
4866        };
4867
4868        let time = |label: &str, command: &str, wrapped: bool, reps: u32| {
4869            let mut samples = Vec::new();
4870            for _ in 0..reps {
4871                let start = std::time::Instant::now();
4872                let (ok, output) = if wrapped {
4873                    run_bounded_gate_command_sandboxed(&repo_root, command, &policy)
4874                } else {
4875                    run_bounded_gate_command(&repo_root, command)
4876                };
4877                let elapsed = start.elapsed();
4878                assert!(ok, "{label} run failed: {output}");
4879                samples.push(elapsed);
4880            }
4881            let total: Duration = samples.iter().sum();
4882            let mean = total / samples.len() as u32;
4883            let min = samples.iter().min().unwrap();
4884            println!("{label}: reps={reps} mean={mean:.3?} min={min:.3?} all={samples:?}");
4885            mean
4886        };
4887
4888        let micro_unwrapped = time("micro  unwrapped (true)", "true", false, 50);
4889        let micro_wrapped = time("micro  wrapped   (true)", "true", true, 50);
4890        println!(
4891            "micro delta per spawn: {:?} ({:+.1}%)",
4892            micro_wrapped.saturating_sub(micro_unwrapped),
4893            (micro_wrapped.as_secs_f64() / micro_unwrapped.as_secs_f64() - 1.0) * 100.0
4894        );
4895        let gate_unwrapped = time("gate   unwrapped", &payload, false, reps);
4896        let gate_wrapped = time("gate   wrapped  ", &payload, true, reps);
4897        println!(
4898            "gate delta: {:?} ({:+.2}%) on `{}`",
4899            gate_wrapped.saturating_sub(gate_unwrapped),
4900            (gate_wrapped.as_secs_f64() / gate_unwrapped.as_secs_f64() - 1.0) * 100.0,
4901            payload
4902        );
4903    }
4904}