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