Skip to main content

kranz_engine/
sandbox.rs

1//! OS sandbox profile/argv generation — Tier 2 filesystem/network containment.
2//! See docs/scoping/worker-sandboxing.md tier 2.
3//!
4//! macOS uses Seatbelt (`sandbox-exec`) for filesystem isolation. Seatbelt
5//! cannot express hostname egress allowlists (it accepts only `*`/`localhost`
6//! network hosts), so `fs+net` on macOS restricts outbound TCP to loopback
7//! and the run routes through the userspace filtering egress proxy
8//! (`crate::egress_proxy`), which enforces the per-host allowlist at CONNECT
9//! time. Linux uses bubblewrap for filesystem isolation; `fs+net` fails closed
10//! with `--unshare-net` because bwrap alone cannot express a hostname egress
11//! allowlist (and its netns cannot reach a host proxy — out of scope for v1).
12//!
13//! Write scope (P1, ticket sandbox-writable-scope): a session may write only
14//! its working directory, its per-session private scratch root
15//! (`SandboxInputs::tmpdir` — NOT the shared system temp root, which would
16//! expose every sibling mission's worktrees and merge scratch), and
17//! operator-declared `extraWrite` paths. The mission dir is never
18//! worker-writable; its engine-owned metadata (audit log, state snapshot,
19//! control inbox, transcripts) is additionally denied/masked so it stays
20//! read-only even in checkout mode, where the writable `session_cwd` is an
21//! ancestor of the mission dir.
22//!
23//! Mandatory validator containment (ticket `validator-mandatory-containment`):
24//! VALIDATOR sessions are the one class wrapped regardless of
25//! `sandbox.enforce` — the validator is the adversarial reader the whole
26//! gate rests on, so its isolation cannot be operator-opt-in.
27//! [`resolve_validator_containment`] resolves the posture: the role's own
28//! enforced sandbox plus the real-checkout read-deny set when enforcement
29//! is configured, the mandatory `fs`-tier wrap when it is not, and — where
30//! the platform or the selected backend cannot contain — a FAIL-CLOSED
31//! refusal by default (ticket `validator-containment-degrade-fail-closed`,
32//! 14th-pass review: the loud degrade reopens the modify→use→restore path,
33//! so it is now the explicit opt-in `validatorAllowUncontainedDegrade`, never
34//! the default).
35//! The read-deny set ([`validator_read_deny_entries`]) closes the broad
36//! read allow over the real checkout's source tree — the snapshot
37//! worktree is the sole writable root and the only tree the validator can
38//! read — keeping the narrow `.git`/`.kranz` carve-outs the inspection
39//! legitimately needs.
40
41use std::path::{Path, PathBuf};
42
43/// Default egress needed by Claude/Anthropic sessions under `fs+net`.
44pub const DEFAULT_EGRESS: &[&str] = &["api.anthropic.com:443", "*.anthropic.com:443"];
45
46/// Inputs used to build a session sandbox.
47#[derive(Debug, Clone)]
48pub struct SandboxInputs {
49    pub enforce: crate::types::SandboxEnforce,
50    pub session_cwd: PathBuf,
51    pub mission_dir: PathBuf,
52    /// The session-PRIVATE scratch root — the only TMPDIR-side path the
53    /// session may write (the cleared child env points `HOME`/`TMPDIR`
54    /// under it; see `crate::agent_env`). NOT the shared system temp root:
55    /// allowing all of `TMPDIR` made every sibling mission's worktree and
56    /// merge scratch worker-writable (P1, ticket sandbox-writable-scope).
57    /// `build_inputs` defaults it to the mission's gitignored probe scratch;
58    /// the runner overrides it per session with
59    /// `crate::backend_claude::scratch_home_root(session_id)`.
60    pub tmpdir: PathBuf,
61    pub extra_write: Vec<PathBuf>,
62    pub egress: Vec<String>,
63    /// Mandatory validator containment (ticket
64    /// `validator-mandatory-containment`): the REAL checkout roots a
65    /// VALIDATOR session must not read — the checkout the snapshot was taken
66    /// from, plus the primary checkout when worktree mode separates them.
67    /// Empty for every non-validator session (workers, orchestrator turns)
68    /// and for engine-run gates: those legitimately work in the real tree,
69    /// and an empty set keeps the generated profile/argv byte-identical to
70    /// the pre-containment shape. The validator's own snapshot worktree is
71    /// never in this set — it lives under the mission dir, which the
72    /// read-deny carve-outs (`<root>/.git`, `<root>/.kranz`) deliberately
73    /// keep reachable; see [`validator_read_deny_entries`].
74    pub validator_read_deny_roots: Vec<PathBuf>,
75}
76
77/// Concrete OS sandbox backend selected for this session.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SandboxBackend {
80    Seatbelt,
81    Bubblewrap,
82    /// Stable Win32 AppContainer profile + path-specific SID ACLs. The
83    /// hostile child is created suspended and Job-owned before resume.
84    AppContainer,
85    /// Tier-3: run the session inside a container (see
86    /// [`crate::sandbox_container`]); `ResolvedSandbox::container` is `Some`.
87    Container,
88}
89
90/// How a role's `enforce` setting maps onto the current platform.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum SandboxDecision {
93    /// Enforcement is off; no sandbox is attached.
94    Off,
95    /// Enforcement is requested and the platform supports it.
96    Enforce(SandboxBackend),
97    /// Enforcement is requested but the platform can't honor it; run-level
98    /// callers must refuse rather than proceed unsandboxed.
99    UnsupportedWarn,
100}
101
102fn enforce_label(enforce: crate::types::SandboxEnforce) -> &'static str {
103    match enforce {
104        crate::types::SandboxEnforce::Off => "off",
105        crate::types::SandboxEnforce::Fs => "fs",
106        crate::types::SandboxEnforce::FsNet => "fs+net",
107    }
108}
109
110/// Pure decision fn: given a role's `enforce` setting and the target OS
111/// (`std::env::consts::OS`-shaped string), decide whether the session gets an
112/// enforced sandbox. Parameterized on `target_os` so it is testable
113/// cross-platform.
114pub fn platform_support(enforce: crate::types::SandboxEnforce, target_os: &str) -> SandboxDecision {
115    match enforce {
116        crate::types::SandboxEnforce::Off => SandboxDecision::Off,
117        crate::types::SandboxEnforce::Fs if target_os == "macos" => {
118            SandboxDecision::Enforce(SandboxBackend::Seatbelt)
119        }
120        crate::types::SandboxEnforce::FsNet if target_os == "macos" => {
121            // Seatbelt's loopback-only egress profile plus the egress proxy's
122            // per-host allowlist (crate::egress_proxy): the hostname rules the
123            // SBPL cannot express live in the proxy, not the profile.
124            SandboxDecision::Enforce(SandboxBackend::Seatbelt)
125        }
126        crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
127            if target_os == "linux" =>
128        {
129            SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
130        }
131        crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
132            if target_os == "windows" =>
133        {
134            SandboxDecision::Enforce(SandboxBackend::AppContainer)
135        }
136        crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet => {
137            SandboxDecision::UnsupportedWarn
138        }
139    }
140}
141
142/// A resolved, enforced sandbox for one session.
143#[derive(Debug, Clone)]
144pub struct ResolvedSandbox {
145    pub backend: SandboxBackend,
146    pub inputs: SandboxInputs,
147    /// Container runtime + image; `Some` iff `backend == Container`.
148    pub container: Option<crate::sandbox_container::ContainerSpec>,
149}
150
151/// Expand a leading `~/` (or `~\` on Windows) in `raw` using the platform home
152/// variable; otherwise return `raw` unchanged as a `PathBuf`. `pub(crate)` so
153/// the engine-run gate wrap (`crate::command_exec::resolve_gate_sandbox`)
154/// builds `extra_write` inputs with the SAME expansion sessions get — never a
155/// second hand-rolled rule.
156///
157/// Windows reads `USERPROFILE`, matching [`crate::paths::global_config`]. A
158/// natively launched `kranz.exe` has no `HOME` (only shells like Git Bash
159/// inject one), so keying solely off `HOME` silently left `~/...` literal and
160/// the sandbox grant then pointed at a directory named `~`.
161pub(crate) fn expand_tilde(raw: &str) -> PathBuf {
162    let rest = raw.strip_prefix("~/").or_else(|| {
163        if cfg!(windows) {
164            raw.strip_prefix("~\\")
165        } else {
166            None
167        }
168    });
169    if let Some(rest) = rest {
170        if let Some(home) = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
171            .filter(|value| !value.is_empty())
172        {
173            return PathBuf::from(home).join(rest);
174        }
175    }
176    PathBuf::from(raw)
177}
178
179/// Resolve a role's sandbox config into an (optional) enforced sandbox for
180/// one session, plus an optional one-time warning string.
181///
182/// Returns `(Some(ResolvedSandbox), None)` when enforcement is requested and
183/// supported, `(None, None)` when enforcement is off, and
184/// `(None, Some(warning))` when enforcement is requested but unsupported on
185/// this platform.
186pub fn resolve_for_session(
187    role_sandbox: &crate::types::SandboxConfig,
188    session_cwd: &Path,
189    mission_dir: &Path,
190) -> (Option<ResolvedSandbox>, Option<String>) {
191    let runtime = crate::sandbox_container::detect();
192    let resolved = resolve_for_session_target(
193        role_sandbox,
194        session_cwd,
195        mission_dir,
196        std::env::consts::OS,
197        command_available("bwrap"),
198        runtime,
199        session_mount_proof(role_sandbox, session_cwd, mission_dir, runtime),
200    );
201    prewarm_xcrun_for_resolved_seatbelt(resolved.0.as_ref());
202    resolved
203}
204
205/// Apple command-line-tool shims refresh a per-user `xcrun_db*` file even for
206/// read-only Git commands. The profile correctly refuses that shared write,
207/// so refresh the cache outside the sandbox once per resolved Seatbelt
208/// session. Gate wrappers use the same bounded helper; command wrapping never
209/// performs the prewarm itself.
210#[cfg(target_os = "macos")]
211fn prewarm_xcrun_for_resolved_seatbelt(sandbox: Option<&ResolvedSandbox>) {
212    if sandbox.is_some_and(|sandbox| sandbox.backend == SandboxBackend::Seatbelt) {
213        crate::command_exec::prewarm_xcrun_cache_outside_sandbox();
214    }
215}
216
217#[cfg(not(target_os = "macos"))]
218fn prewarm_xcrun_for_resolved_seatbelt(_sandbox: Option<&ResolvedSandbox>) {}
219
220/// Take a bind-mount proof only where resolution needs one.
221///
222/// Linux is CI-proven and Windows is refused outright, so neither pays for a
223/// probe. Everything else pays once per host, cached, and only when a
224/// container was actually requested.
225pub(crate) fn session_mount_proof(
226    role_sandbox: &crate::types::SandboxConfig,
227    session_cwd: &Path,
228    mission_dir: &Path,
229    runtime: Option<crate::sandbox_container::ContainerRuntime>,
230) -> Option<crate::sandbox_container::MountProof> {
231    if role_sandbox.provider != crate::types::SandboxProvider::Container
232        || role_sandbox.enforce == crate::types::SandboxEnforce::Off
233        || cfg!(target_os = "linux")
234        || cfg!(target_os = "windows")
235    {
236        return None;
237    }
238    let runtime = runtime?;
239    let extra_write: Vec<PathBuf> = role_sandbox
240        .extra_write
241        .iter()
242        .map(|raw| expand_tilde(raw))
243        .collect();
244    Some(crate::sandbox_container::prove_mount_roots(
245        runtime,
246        &crate::sandbox_container::declared_mount_roots(session_cwd, mission_dir, &extra_write),
247        role_sandbox
248            .image
249            .as_deref()
250            .unwrap_or(crate::sandbox_container::DEFAULT_IMAGE),
251    ))
252}
253
254fn resolve_for_session_target(
255    role_sandbox: &crate::types::SandboxConfig,
256    session_cwd: &Path,
257    mission_dir: &Path,
258    target_os: &str,
259    bwrap_available: bool,
260    container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
261    container_mount_proof: Option<crate::sandbox_container::MountProof>,
262) -> (Option<ResolvedSandbox>, Option<String>) {
263    if role_sandbox.provider == crate::types::SandboxProvider::Container {
264        return resolve_container_target(
265            role_sandbox,
266            session_cwd,
267            mission_dir,
268            target_os,
269            container_runtime,
270            container_mount_proof,
271        );
272    }
273    match platform_support(role_sandbox.enforce, target_os) {
274        SandboxDecision::Off => (None, None),
275        SandboxDecision::UnsupportedWarn => (
276            None,
277            Some(format!(
278                "sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing to run unsandboxed",
279                enforce_label(role_sandbox.enforce)
280            )),
281        ),
282        SandboxDecision::Enforce(SandboxBackend::Bubblewrap) if !bwrap_available => (
283            None,
284            Some(format!(
285                "sandbox enforce:{} requested on linux but `bwrap` was not found; refusing to run unsandboxed",
286                enforce_label(role_sandbox.enforce)
287            )),
288        ),
289        SandboxDecision::Enforce(backend) => (
290            Some(ResolvedSandbox {
291                backend,
292                inputs: build_inputs(role_sandbox, session_cwd, mission_dir),
293                container: None,
294            }),
295            None,
296        ),
297    }
298}
299
300/// Resolve the tier-3 container provider: `enforce: off` stays unsandboxed;
301/// `fs+net` with an empty egress list keeps the `--network none` hard egress
302/// boundary. A non-empty list is supported only by Docker: the runner creates
303/// a unique internal network and authenticated filtering relay before spawn.
304/// Other runtimes refuse rather than silently falling back to their bridge.
305/// A requested container with no runtime on PATH is refused.
306fn resolve_container_target(
307    role_sandbox: &crate::types::SandboxConfig,
308    session_cwd: &Path,
309    mission_dir: &Path,
310    target_os: &str,
311    runtime: Option<crate::sandbox_container::ContainerRuntime>,
312    mount_proof: Option<crate::sandbox_container::MountProof>,
313) -> (Option<ResolvedSandbox>, Option<String>) {
314    if role_sandbox.enforce == crate::types::SandboxEnforce::Off {
315        return (None, None);
316    }
317    // Linux carries a continuously enforced CI receipt for the shipped
318    // bind-mount, authority-mask, and egress contracts, so it needs no
319    // per-host evidence. macOS cannot renew that receipt in CI, because
320    // hosted runners are already guests and cannot provision the VM the
321    // runtime needs. Rather than claim macOS on a receipt that expires or
322    // deny a host that demonstrably works, require the evidence AT RUN TIME:
323    // a macOS host is supported exactly when this runtime proves it really
324    // shares the mounted path. Windows stays refused whatever a probe says,
325    // because its gap is the POSIX guest-path and `/dev/null` authority-mask
326    // contract, which no mount proof addresses.
327    if target_os == "windows" {
328        return (
329            None,
330            Some(format!(
331                "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 under an unverified container mount contract",
332                enforce_label(role_sandbox.enforce)
333            )),
334        );
335    }
336    if target_os != "linux" {
337        match mount_proof {
338            Some(crate::sandbox_container::MountProof::Proven) => {}
339            Some(crate::sandbox_container::MountProof::Failed(reason)) => {
340                return (
341                    None,
342                    Some(format!(
343                        "sandbox provider:container with enforce:{} refused on target_os={target_os}: {reason}",
344                        enforce_label(role_sandbox.enforce)
345                    )),
346                );
347            }
348            None => {
349                return (
350                    None,
351                    Some(format!(
352                        "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 under an unverified container mount contract; use sandbox.provider=\"process\" for native host containment",
353                        enforce_label(role_sandbox.enforce)
354                    )),
355                );
356            }
357        }
358    }
359    let Some(runtime) = runtime else {
360        return (
361            None,
362            Some(
363                "sandbox provider:container requested but no container runtime (docker/podman/nerdctl/container) found on PATH; refusing to run unsandboxed"
364                    .to_string(),
365            ),
366        );
367    };
368    if role_sandbox.enforce == crate::types::SandboxEnforce::FsNet
369        && !role_sandbox.egress.is_empty()
370        && runtime != crate::sandbox_container::ContainerRuntime::Docker
371    {
372        return (
373            None,
374            Some(format!(
375                "sandbox provider:container with enforce:fs+net and a non-empty egress list requires Docker's internal-network boundary; runtime {} is not live-proven for that posture — refusing to run",
376                runtime.binary()
377            )),
378        );
379    }
380    (
381        Some(ResolvedSandbox {
382            backend: SandboxBackend::Container,
383            inputs: build_inputs(role_sandbox, session_cwd, mission_dir),
384            container: Some(crate::sandbox_container::ContainerSpec {
385                runtime,
386                image: role_sandbox
387                    .image
388                    .clone()
389                    .unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
390                network: None,
391                name: None,
392            }),
393        }),
394        None,
395    )
396}
397
398fn build_inputs(
399    role_sandbox: &crate::types::SandboxConfig,
400    session_cwd: &Path,
401    mission_dir: &Path,
402) -> SandboxInputs {
403    let extra_write = role_sandbox
404        .extra_write
405        .iter()
406        .map(|s| expand_tilde(s))
407        .collect();
408    SandboxInputs {
409        enforce: role_sandbox.enforce,
410        session_cwd: session_cwd.to_path_buf(),
411        mission_dir: mission_dir.to_path_buf(),
412        // Default session-private scratch: the mission's gitignored
413        // contract/sandbox scratch home — the shape the engine's own probes
414        // (preflight contract commands, whose cleared env points HOME/TMPDIR
415        // at `runs/contract-home`) execute under. The runner overrides this
416        // per session with the session's private scratch root (see the
417        // `SandboxInputs::tmpdir` doc); warn-only resolves never execute
418        // under the profile, so the default is never their concern.
419        tmpdir: mission_dir.join("runs").join("contract-home"),
420        extra_write,
421        egress: role_sandbox.egress.clone(),
422        // Session sandboxes never read-deny the tree they work in — the
423        // validator containment resolution sets this explicitly.
424        validator_read_deny_roots: Vec::new(),
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Mandatory validator containment (ticket validator-mandatory-containment)
430// ---------------------------------------------------------------------------
431
432/// The outcome of resolving one validator session's MANDATORY containment
433/// (ticket `validator-mandatory-containment`). The validator is the
434/// adversarial reader the whole gate rests on; its isolation must not depend
435/// on the operator opting into enforcement, so `sandbox.enforce: off` (the
436/// default) no longer means an unwrapped validator — where the platform has
437/// a process-sandbox tier and the selected backend can apply it, the session
438/// is wrapped regardless.
439#[derive(Debug)]
440pub struct ValidatorContainment {
441    /// The sandbox to attach to the validator's [`crate::backend::SessionSpec`]
442    /// — `Some` whenever a wrap applies (the role's own enforced sandbox
443    /// plus the read-deny roots, or the mandatory `fs`-tier wrap under
444    /// `enforce: off`), `None` only when containment degraded (see `note`).
445    pub sandbox: Option<ResolvedSandbox>,
446    /// The LOUD operator-facing posture note when containment could not be
447    /// applied AND the operator opted into the degrade
448    /// (`validatorAllowUncontainedDegrade`) — an unsupported platform, a
449    /// linux without `bwrap`, or a backend that does not honor the resolved
450    /// sandbox. The orchestrator surfaces it as a decision per validator
451    /// spawn (so every validation round carries it); `None` when the session
452    /// is contained. Without the opt-in the resolution FAILS CLOSED instead
453    /// (ticket `validator-containment-degrade-fail-closed`, 14th-pass
454    /// review): the degrade reopens the modify→use→restore path the
455    /// mandatory-containment work was built to close, so snapshot
456    /// separation plus the after-fingerprint tripwire alone are no longer
457    /// the default posture.
458    pub note: Option<String>,
459}
460
461/// Resolve the containment posture for one validator session (both roles —
462/// scrutiny and functional run the same shape).
463///
464/// `session_cwd` is the throwaway snapshot worktree — the profile's sole
465/// writable root alongside the session-private scratch. `read_deny_roots`
466/// are the REAL checkout roots the snapshot was taken from (the active tree,
467/// plus the primary checkout when worktree mode separates them); their
468/// source trees become read-denied in the generated profile/argv
469/// ([`validator_read_deny_entries`]). `backend` decides whether the wrap can
470/// be honored at all: only the claude backend applies a resolved sandbox
471/// ([`crate::types::BackendKind::supports_sandbox_enforcement`]).
472///
473/// `enforce != off` keeps today's fail-closed posture byte-for-byte: the
474/// role's own resolution governs (an unsupported platform or missing `bwrap`
475/// is an Err, mirroring the runner's `resolve_sandbox_or_refuse`), with the
476/// read-deny roots ATTACHED on the process tier. The container provider
477/// resolves untouched — its read-only rootfs and named mounts are already
478/// the stronger containment, and the real tree is simply not mounted.
479///
480/// `enforce == off` is the case this ticket exists for: the mandatory
481/// `fs`-tier wrap (write containment with the validator's API egress intact;
482/// no operator `extraWrite` widening — the snapshot is the sole writable
483/// root) wherever the platform supports it and the backend can apply it.
484/// Everywhere else the resolution FAILS CLOSED (ticket
485/// `validator-containment-degrade-fail-closed`, 14th-pass review — this
486/// reverses the 224fa73 loud-degrade default) unless
487/// `allow_uncontained_degrade` (the `validatorAllowUncontainedDegrade`
488/// config flag) opts this repo back into the loud degradation note.
489pub fn resolve_validator_containment(
490    role_sandbox: &crate::types::SandboxConfig,
491    backend: crate::types::BackendKind,
492    session_cwd: &Path,
493    mission_dir: &Path,
494    read_deny_roots: &[PathBuf],
495    allow_uncontained_degrade: bool,
496) -> crate::error::Result<ValidatorContainment> {
497    let runtime = crate::sandbox_container::detect();
498    let resolved = resolve_validator_containment_target(
499        role_sandbox,
500        backend,
501        session_cwd,
502        mission_dir,
503        read_deny_roots,
504        allow_uncontained_degrade,
505        std::env::consts::OS,
506        command_available("bwrap"),
507        runtime,
508        session_mount_proof(role_sandbox, session_cwd, mission_dir, runtime),
509    );
510    if let Ok(containment) = &resolved {
511        prewarm_xcrun_for_resolved_seatbelt(containment.sandbox.as_ref());
512    }
513    resolved
514}
515
516/// [`resolve_validator_containment`] parameterized on the target OS, `bwrap`
517/// availability, and container runtime so the decision matrix is testable
518/// cross-platform (mirrors [`resolve_for_session_target`] /
519/// `crate::command_exec::resolve_gate_sandbox_target`).
520#[allow(clippy::too_many_arguments)]
521fn resolve_validator_containment_target(
522    role_sandbox: &crate::types::SandboxConfig,
523    backend: crate::types::BackendKind,
524    session_cwd: &Path,
525    mission_dir: &Path,
526    read_deny_roots: &[PathBuf],
527    allow_uncontained_degrade: bool,
528    target_os: &str,
529    bwrap_available: bool,
530    container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
531    container_mount_proof: Option<crate::sandbox_container::MountProof>,
532) -> crate::error::Result<ValidatorContainment> {
533    if role_sandbox.enforce != crate::types::SandboxEnforce::Off {
534        // The role's own resolution governs; an enforced pair with a
535        // backend that cannot honor it is already refused by
536        // `config::validate` (fail closed) before a mission reaches here.
537        let (sandbox, warn) = resolve_for_session_target(
538            role_sandbox,
539            session_cwd,
540            mission_dir,
541            target_os,
542            bwrap_available,
543            container_runtime,
544            container_mount_proof,
545        );
546        return match sandbox {
547            Some(mut resolved) => {
548                // Process-tier wraps (Seatbelt/bwrap) get the read-deny
549                // roots; the container tier's mounts are the containment
550                // and simply do not include the real tree.
551                if resolved.backend != SandboxBackend::Container {
552                    resolved.inputs.validator_read_deny_roots = read_deny_roots.to_vec();
553                }
554                Ok(ValidatorContainment {
555                    sandbox: Some(resolved),
556                    note: None,
557                })
558            }
559            // Fail closed, mirroring resolve_sandbox_or_refuse: enforcement
560            // was requested and cannot be honored on this platform.
561            None => Err(crate::error::EngineError::Backend(warn.unwrap_or_else(|| {
562                format!(
563                    "sandbox enforce:{} requested but no sandbox could be resolved; refusing to run unsandboxed",
564                    role_sandbox.enforce.as_str()
565                )
566            }))),
567        };
568    }
569
570    // enforce: off — MANDATORY containment. The provider is ignored here:
571    // `provider: container` with `enforce: off` documents "no sandboxing,
572    // same as today", and the mandatory wrap is the process tier.
573    //
574    // Where the wrap cannot apply, the default is FAIL CLOSED (ticket
575    // validator-containment-degrade-fail-closed, 14th-pass review — this
576    // REVERSES the 224fa73 loud-degrade-by-default decision: a degraded
577    // validator runs with snapshot separation and the tripwire only, which
578    // reopens the modify→use→restore path the wrap exists to close).
579    // `validatorAllowUncontainedDegrade` opts this repo back into the loud
580    // per-round degradation note.
581    let uncontained = |why: String, note: String| -> crate::error::Result<ValidatorContainment> {
582        if !allow_uncontained_degrade {
583            return Err(crate::error::EngineError::Config(format!(
584                "mandatory validator containment cannot apply ({why}); refusing to run an \
585                 uncontained validator — the degraded posture reopens the modify→use→restore \
586                 path the wrap exists to close (ticket \
587                 validator-containment-degrade-fail-closed). To run validators here anyway, \
588                 set \"validatorAllowUncontainedDegrade\": true in .kranz/config.json (the \
589                 loud per-round degrade returns); otherwise use a containable platform \
590                 (macOS, or linux with `bwrap` on PATH) and the claude validator backend"
591            )));
592        }
593        Ok(ValidatorContainment {
594            sandbox: None,
595            note: Some(note),
596        })
597    };
598    if !backend.supports_sandbox_enforcement() {
599        return uncontained(
600            format!(
601                "the {} backend does not apply the resolved sandbox profile",
602                backend.as_str()
603            ),
604            format!(
605                "validator sessions on the {} backend cannot be OS-sandbox-contained (only the \
606                 claude backend applies the resolved sandbox profile); \
607                 validatorAllowUncontainedDegrade is set, so this validator runs with \
608                 snapshot isolation and the after-fingerprint tripwire only — the real checkout \
609                 is reachable from the session. Select a claude validator backend for mandatory \
610                 containment (ticket validator-mandatory-containment; the degrade is opt-in per \
611                 validator-containment-degrade-fail-closed)",
612                backend.as_str()
613            ),
614        );
615    }
616    let degraded = |why: String| {
617        uncontained(
618            why.clone(),
619            format!(
620                "validator sessions are NOT OS-sandbox-contained ({why}); \
621                 validatorAllowUncontainedDegrade is set, so the validator still runs in its \
622                 throwaway snapshot with the after-fingerprint tripwire on the real checkout, \
623                 but hostile validator code could walk to the real checkout and restore bytes \
624                 before the fingerprint — containment here is the snapshot's physical \
625                 separation only (ticket validator-mandatory-containment; the degrade is \
626                 opt-in per validator-containment-degrade-fail-closed)"
627            ),
628        )
629    };
630    match platform_support(crate::types::SandboxEnforce::Fs, target_os) {
631        // Unreachable (Fs is not Off) — platform_support is the shared
632        // vocabulary, so the match stays exhaustive anyway.
633        SandboxDecision::Off => unreachable!("fs never decides Off"),
634        SandboxDecision::UnsupportedWarn => {
635            degraded(format!("target_os={target_os} has no process-sandbox tier"))
636        }
637        SandboxDecision::Enforce(SandboxBackend::Bubblewrap) if !bwrap_available => {
638            degraded("linux without `bwrap` on PATH".to_string())
639        }
640        // platform_support never selects Container (that resolution is
641        // resolve_container_target's, and the enforce!=off arm above owns
642        // the provider) — the match stays exhaustive anyway.
643        SandboxDecision::Enforce(SandboxBackend::Container) => {
644            unreachable!("process tier only")
645        }
646        SandboxDecision::Enforce(backend_kind) => Ok(ValidatorContainment {
647            sandbox: Some(ResolvedSandbox {
648                backend: backend_kind,
649                inputs: SandboxInputs {
650                    // The fs tier: write containment with network intact
651                    // (denying egress would brick the validator's API
652                    // session — the same reason the session profile
653                    // allows network under fs).
654                    enforce: crate::types::SandboxEnforce::Fs,
655                    session_cwd: session_cwd.to_path_buf(),
656                    mission_dir: mission_dir.to_path_buf(),
657                    // Pinned per session by the runner to the session's
658                    // private scratch root (the same pin
659                    // resolve_sandbox_or_refuse applies); the default
660                    // here is the probe-shaped contract home.
661                    tmpdir: mission_dir.join("runs").join("contract-home"),
662                    // NO operator extraWrite widening under the mandatory
663                    // wrap: the snapshot worktree is the sole writable
664                    // root (plus the session-private scratch).
665                    extra_write: Vec::new(),
666                    egress: Vec::new(),
667                    validator_read_deny_roots: read_deny_roots.to_vec(),
668                },
669                container: None,
670            }),
671            note: None,
672        }),
673    }
674}
675
676pub(crate) fn command_available(name: &str) -> bool {
677    let Some(path) = std::env::var_os("PATH") else {
678        return false;
679    };
680    // Windows PATH entries carry no extension; the executable suffixes live in
681    // PATHEXT. Probing the bare name alone reports every Windows executable as
682    // missing (`grep` vs `grep.exe`).
683    let mut candidates = vec![name.to_string()];
684    if cfg!(windows) {
685        let pathext =
686            std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
687        candidates.extend(
688            pathext
689                .split(';')
690                .filter(|ext| !ext.is_empty())
691                .map(|ext| format!("{name}{ext}")),
692        );
693    }
694    std::env::split_paths(&path).any(|dir| candidates.iter().any(|name| dir.join(name).is_file()))
695}
696
697/// Absolutize a path without requiring it to exist: canonicalize if possible,
698/// otherwise join it onto the current directory when relative.
699pub(crate) fn absolutize(path: &Path) -> PathBuf {
700    if let Ok(canon) = path.canonicalize() {
701        return canon;
702    }
703    // Future authority paths still need their existing ancestors resolved
704    // (notably /var -> /private/var), even before their leaf is created.
705    for ancestor in path.ancestors().skip(1) {
706        if let Ok(canon) = ancestor.canonicalize() {
707            if let Ok(suffix) = path.strip_prefix(ancestor) {
708                return canon.join(suffix);
709            }
710        }
711    }
712    if path.is_absolute() {
713        path.to_path_buf()
714    } else {
715        std::env::current_dir()
716            .map(|cwd| cwd.join(path))
717            .unwrap_or_else(|_| path.to_path_buf())
718    }
719}
720
721// Mount destinations name the inspected leaf without following its links.
722fn lexical_absolute(path: &Path) -> PathBuf {
723    if path.is_absolute() {
724        path.to_path_buf()
725    } else {
726        std::env::current_dir()
727            .map(|cwd| cwd.join(path))
728            .unwrap_or_else(|_| path.to_path_buf())
729    }
730}
731
732pub(crate) fn global_authority_dir() -> Option<PathBuf> {
733    crate::paths::global_config().and_then(|path| path.parent().map(absolutize))
734}
735
736/// Escape a path for embedding in an SBPL string literal.
737pub(crate) fn escape_sbpl_literal(path: &Path) -> String {
738    escape_sbpl_string(&path.to_string_lossy())
739}
740
741fn escape_sbpl_string(s: &str) -> String {
742    s.replace('\\', "\\\\").replace('"', "\\\"")
743}
744
745/// Escape a path for embedding in an SBPL `#"..."` regex literal: every
746/// regex metacharacter is backslash-escaped so the path matches literally
747/// (temp-dir names carry no metacharacters in practice, but a repo root
748/// might — `.` in a directory name must not become an any-char match).
749pub(crate) fn escape_sbpl_regex(path: &Path) -> String {
750    let mut out = String::new();
751    for ch in path.to_string_lossy().chars() {
752        match ch {
753            '\\' => out.push_str("\\\\"),
754            '"' => out.push_str("\\\""),
755            c if "^.+$*?()[]{}|".contains(c) => {
756                out.push('\\');
757                out.push(c);
758            }
759            c => out.push(c),
760        }
761    }
762    out
763}
764
765/// The session's writable roots: its working directory (worktree or, in
766/// checkout mode, the repo root), its private scratch root, and each
767/// operator-declared `extraWrite` entry. The mission dir and the shared
768/// system temp root are deliberately NOT here (ticket
769/// sandbox-writable-scope): the engine writes mission metadata from OUTSIDE
770/// the sandbox, and whole-`TMPDIR` access made sibling missions' worktrees
771/// writable. Mission metadata that would still be reachable through an
772/// allowed ancestor (checkout mode: `session_cwd` is the repo root) is
773/// carved back out by [`mission_write_denies`].
774pub(crate) fn write_allowlist(inputs: &SandboxInputs) -> Vec<PathBuf> {
775    let mut write_paths: Vec<PathBuf> =
776        vec![absolutize(&inputs.session_cwd), absolutize(&inputs.tmpdir)];
777    write_paths.extend(inputs.extra_write.iter().map(|p| absolutize(p)));
778    write_paths.sort();
779    write_paths.dedup();
780    write_paths
781}
782
783/// The mission-metadata write-deny set: engine-owned files a sandboxed
784/// session must never write even when an allowed ancestor (checkout mode's
785/// `session_cwd` = repo root) would otherwise cover them. A worker that
786/// could rewrite `events.jsonl` defeats the append-only audit log; one that
787/// could drop files into `control/` injects control commands; one that
788/// could rewrite `runs/*.jsonl` forges transcripts. Like
789/// [`authority_read_deny_paths`], both the raw and canonical mission-dir
790/// forms are expanded (Seatbelt matches canonical paths; the child may
791/// address either form).
792pub(crate) struct MissionWriteDenies {
793    /// Engine-written files at the mission-dir root: literal write denies
794    /// (Seatbelt) / read-only directory views (bwrap).
795    pub files: Vec<PathBuf>,
796    /// Current and sibling `control/` inboxes: subpath write deny / tmpfs shadow.
797    pub control_dirs: Vec<PathBuf>,
798    /// The `runs/` transcript dirs: `runs/*.jsonl` regex write deny
799    /// (Seatbelt) / read-only directory binds (bwrap). Session scratch and
800    /// the current worktree keep their explicit writable roots.
801    pub runs_dirs: Vec<PathBuf>,
802}
803
804/// Engine-written files at the mission-dir root a sandboxed session must
805/// never write (see [`MissionWriteDenies`]).
806pub(crate) const MISSION_METADATA_FILES: &[&str] = &[
807    "events.jsonl",
808    "events.jsonl.lock",
809    "state.json",
810    "state.json.tmp",
811    "estimate.json",
812];
813
814pub(crate) fn mission_write_denies(inputs: &SandboxInputs) -> MissionWriteDenies {
815    let mut denies = MissionWriteDenies {
816        files: Vec::new(),
817        control_dirs: Vec::new(),
818        runs_dirs: Vec::new(),
819    };
820    let mut mission_dirs = vec![inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)];
821    // Checkout mode makes the whole repository writable. Protect sibling
822    // missions as well as the current one; their inboxes have equal authority.
823    if let Some(missions) = inputs
824        .mission_dir
825        .parent()
826        .filter(|path| path.ends_with("missions"))
827    {
828        if let Ok(entries) = std::fs::read_dir(missions) {
829            for entry in entries.flatten() {
830                if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
831                    mission_dirs.extend([entry.path(), absolutize(&entry.path())]);
832                }
833            }
834        }
835    }
836    for mission_dir in mission_dirs {
837        for name in MISSION_METADATA_FILES {
838            denies.files.push(mission_dir.join(name));
839        }
840        denies.control_dirs.push(mission_dir.join("control"));
841        denies.runs_dirs.push(mission_dir.join("runs"));
842    }
843    denies
844}
845
846/// The operator's real Cargo home: ambient `CARGO_HOME` when set, else
847/// `~/.cargo` when HOME is set — the same resolution
848/// `crate::agent_env::toolchain_var_value("CARGO_HOME", ".cargo")` applies
849/// when it builds the isolated contract home. The two MUST stay in
850/// lockstep: whatever the isolated home can LINK is what the profile must
851/// be able to DENY writes to (see [`cargo_cache_write_deny_paths`]).
852fn operator_cargo_home() -> Option<PathBuf> {
853    std::env::var_os("CARGO_HOME")
854        .map(PathBuf::from)
855        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
856}
857
858/// The operator's REAL shared Cargo cache directories —
859/// `<cargo home>/registry` and `<cargo home>/git` — in both raw and
860/// canonicalized forms (the `/var` ↔ `/private/var` idiom the write
861/// allowlist already uses; Seatbelt matches canonical paths and a child
862/// may address either form). Above the copy ceiling the isolated contract
863/// home LINKS these in (`crate::agent_env::cache_only_cargo_home`'s
864/// documented residual trade), so every sandbox profile must deny WRITES
865/// to them explicitly (13th-pass review, P1): deny-default covers the
866/// common case, but only an explicit deny survives EVERY allow — an
867/// operator `extraWrite` of `$HOME`, or any future broadened writable
868/// root, would otherwise silently re-widen the linked cache to writes
869/// from worker-authored contract code, poisoning later builds. PRECISE
870/// scope: the two cache dirs only, never the whole cargo home —
871/// `~/.cargo/bin`'s rustup shims keep their ordinary posture. Reads stay
872/// allowed: the linked cache is the session/gate's registry.
873pub(crate) fn cargo_cache_write_deny_paths() -> Vec<PathBuf> {
874    let Some(cargo_home) = operator_cargo_home() else {
875        return Vec::new();
876    };
877    let mut paths = Vec::with_capacity(4);
878    for base in [cargo_home.clone(), absolutize(&cargo_home)] {
879        paths.push(base.join("registry"));
880        paths.push(base.join("git"));
881    }
882    paths
883}
884
885/// Authority files a sandboxed session must never read, even under the broad
886/// read allow: a read of `serve.token` IS mutation authority over `kranz
887/// serve` (loopback is reachable from every sandbox tier), `serve.read.token`
888/// is its GET-side sibling, `config.json` carries Slack tokens and
889/// remote-workspace credentials, and `domain-terms.local` is the plaintext
890/// clean-room lint vocabulary that must never be readable outside the
891/// engine-side lint (14th-pass review: the mandatory validator wrap's
892/// `.kranz` carve-out — kept for the snapshot — otherwise leaks it).
893/// Derived from the mission dir's canonical `<repo>/.kranz/missions/<id>`
894/// layout. Both the raw and the canonical mission-dir forms are expanded (the
895/// dir exists at spawn time even when the token files do not yet), because
896/// Seatbelt matches against canonical paths — the same `/var` ↔
897/// `/private/var` split the write allowlist handles.
898///
899/// Also denied: `$CARGO_HOME/credentials.toml` AND the legacy extensionless
900/// `$CARGO_HOME/credentials` (or `~/.cargo/...` when CARGO_HOME is unset) —
901/// CARGO_HOME crosses into child envs for registry-cache locality
902/// ([`crate::agent_env`]), but cargo reads BOTH filenames for registry auth
903/// tokens (the legacy one is still supported and takes precedence where
904/// present), so both are the same credential class as the serve token.
905pub(crate) fn authority_read_deny_paths(inputs: &SandboxInputs) -> Vec<PathBuf> {
906    let mut paths = Vec::new();
907    for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
908        if let Some(kranz_dir) = mission_dir
909            .parent()
910            .filter(|path| path.ends_with("missions"))
911            .and_then(Path::parent)
912        {
913            for name in KRANZ_AUTHORITY_FILES {
914                paths.push(kranz_dir.join(name));
915            }
916        }
917    }
918    for name in [
919        "serve.token",
920        "serve.read.token",
921        "config.json",
922        "domain-terms.local",
923    ] {
924        paths.push(absolutize(&inputs.session_cwd).join(".kranz").join(name));
925    }
926    if let Some(global) = crate::paths::global_config() {
927        paths.extend([global.clone(), absolutize(&global)]);
928    }
929    if let Some(cargo_home) = operator_cargo_home() {
930        for base in [cargo_home.clone(), absolutize(&cargo_home)] {
931            paths.push(base.join("credentials.toml"));
932            paths.push(base.join("credentials"));
933        }
934    }
935    // Config layers can be symlinked. Deny the resolved file as well as its
936    // configured name so a canonical read cannot bypass the authority rule.
937    paths.extend(paths.clone().iter().map(|path| absolutize(path)));
938    paths.sort();
939    paths.dedup();
940    paths
941}
942
943/// Authority DIRECTORIES a sandboxed session must never read (14th-pass
944/// review — the directory half of [`authority_read_deny_paths`], denied as
945/// Seatbelt subpaths / bwrap tmpfs shadows):
946///
947/// - `<repo>/.kranz/hook-status/` — the hook-signal projection
948///   (registrations + per-run capability-token hashes). The in-sandbox
949///   `kranz hook-status` relay reads only its session-private spec and POSTs
950///   loopback; the server reads the projection from OUTSIDE the sandbox.
951/// - `<mission_dir>/control/` — the operator→engine control inbox (approve /
952///   pause / config-change commands). The orchestrator polls it from outside
953///   the sandbox; no session ever legitimately reads it. The bwrap write
954///   shadow already hid its contents — this aligns the Seatbelt read posture.
955/// - `<global kranz dir>/keys/` — the operator-side authority keys the
956///   consent substrate is authenticated with (2026-09-01 adversarial audit,
957///   C1/H6: control files and the event log are MACed with a key that must
958///   live OUTSIDE every sandboxed session's readable set, or the MAC proves
959///   nothing against the agent it is meant to exclude). Denied for writes
960///   too ([`authority_write_denies`]): a session that can replace the key
961///   can re-sign whatever it likes.
962pub(crate) fn authority_read_deny_dirs(inputs: &SandboxInputs) -> Vec<PathBuf> {
963    let mut dirs = Vec::new();
964    for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
965        dirs.push(mission_dir.join("control"));
966        if let Some(kranz_dir) = mission_dir
967            .parent()
968            .filter(|path| path.ends_with("missions"))
969            .and_then(Path::parent)
970        {
971            dirs.push(kranz_dir.join("hook-status"));
972        }
973    }
974    if let Some(global) = crate::paths::global_kranz_dir() {
975        dirs.extend([global.clone(), absolutize(&global)]);
976    }
977    for keys_dir in global_key_dirs() {
978        dirs.push(keys_dir);
979    }
980    dirs
981}
982
983/// Mount-backed sandboxes need a private directory namespace, not just
984/// per-file masks: an absent token/config can be created while a worker is
985/// running. Rebind only existing non-authority entries read-only beneath an
986/// empty tmpfs. No placeholder files are created in the host checkout.
987pub(crate) struct AuthorityDirectoryMask {
988    pub path: PathBuf,
989    pub visible_entries: Vec<PathBuf>,
990}
991
992pub(crate) fn authority_directory_masks(inputs: &SandboxInputs) -> Vec<AuthorityDirectoryMask> {
993    let files: std::collections::BTreeSet<_> = authority_read_deny_paths(inputs)
994        .into_iter()
995        .filter_map(|path| Some(absolutize(path.parent()?).join(path.file_name()?)))
996        .collect();
997    let dirs: std::collections::BTreeSet<_> = authority_read_deny_dirs(inputs)
998        .iter()
999        .map(|path| absolutize(path))
1000        .collect();
1001    let mut roots: std::collections::BTreeSet<_> = files
1002        .iter()
1003        .filter_map(|path| path.parent().map(Path::to_path_buf))
1004        .collect();
1005    // Hide denied subdirectories through their parent's private namespace.
1006    // This also works when control/ or hook-status/ does not yet exist under
1007    // a read-only mission bind; setup need not mkdir in the host directory.
1008    for dir in &dirs {
1009        if !roots.contains(dir) {
1010            if let Some(parent) = dir.parent() {
1011                roots.insert(parent.to_path_buf());
1012            }
1013        }
1014    }
1015    let writable = write_allowlist(inputs);
1016    // A denied path may pass through a symlink beneath an allowed write root.
1017    // Mask the link's parent too: masking only its target would let the worker
1018    // replace the alias and redirect the engine's next config read. Ancestors
1019    // outside writable roots need no extra view (e.g. the system /var alias).
1020    for path in authority_read_deny_paths(inputs)
1021        .into_iter()
1022        .chain(authority_read_deny_dirs(inputs))
1023    {
1024        for ancestor in path.ancestors() {
1025            if std::fs::symlink_metadata(ancestor)
1026                .is_ok_and(|metadata| metadata.file_type().is_symlink())
1027            {
1028                if let Some(parent) = ancestor.parent().map(absolutize) {
1029                    if writable.iter().any(|root| parent.starts_with(root)) {
1030                        roots.insert(parent);
1031                    }
1032                }
1033            }
1034        }
1035    }
1036    let roots: std::collections::BTreeSet<_> = roots
1037        .into_iter()
1038        .map(|path| {
1039            if !path.exists() && !writable.iter().any(|root| path.starts_with(root)) {
1040                // bwrap cannot create a mountpoint inside its read-only / bind.
1041                // Mask the nearest existing ancestor instead; denied descendants
1042                // are never rebound, and private write roots are restored below.
1043                path.ancestors()
1044                    .skip(1)
1045                    .find(|parent| parent.is_dir())
1046                    .map(Path::to_path_buf)
1047                    .unwrap_or(path)
1048            } else {
1049                path
1050            }
1051        })
1052        .collect();
1053    roots
1054        .into_iter()
1055        .map(|path| {
1056            let mut visible_entries = Vec::new();
1057            if !dirs.iter().any(|dir| path.starts_with(dir)) {
1058                if let Ok(entries) = std::fs::read_dir(&path) {
1059                    for entry in entries.flatten() {
1060                        let entry_path = entry.path();
1061                        // Never follow a worker-authored link while constructing
1062                        // a privileged bind. Unknown/unreadable entries stay hidden.
1063                        if entry
1064                            .file_type()
1065                            .is_ok_and(|kind| kind.is_dir() || kind.is_file())
1066                            && !files.contains(&entry_path)
1067                            && !dirs.iter().any(|dir| entry_path.starts_with(dir))
1068                        {
1069                            visible_entries.push(entry_path);
1070                        }
1071                    }
1072                }
1073            }
1074            visible_entries.sort();
1075            AuthorityDirectoryMask {
1076                path,
1077                visible_entries,
1078            }
1079        })
1080        .collect()
1081}
1082
1083/// The GLOBAL kranz authority stores (`<global kranz dir>/keys`, where the
1084/// per-repository authority key lives, and `<global kranz dir>/seals`, where
1085/// each mission's seal floor lives), each in raw and canonical form. The
1086/// global dir comes from [`crate::paths::global_kranz_dir`], the same
1087/// resolver the key writer and the seal recorder use, so the deny and the
1088/// writers cannot drift apart. Empty when no global dir resolves (no home,
1089/// no key dir, nothing to deny).
1090fn global_key_dirs() -> Vec<PathBuf> {
1091    let Some(global) = crate::paths::global_kranz_dir() else {
1092        return Vec::new();
1093    };
1094    let mut out = Vec::new();
1095    for name in ["keys", "seals"] {
1096        let dir = global.join(name);
1097        let canonical = absolutize(&dir);
1098        if canonical != dir {
1099            out.push(canonical);
1100        }
1101        out.push(dir);
1102    }
1103    out
1104}
1105
1106/// Repo-level `<repo>/.kranz` stores the ENGINE owns end to end. Named
1107/// explicitly (not only enumerated from the live directory) so the deny
1108/// exists before the store does: a session that could CREATE
1109/// `.kranz/queue/` would own the autoWork drain outright.
1110const KRANZ_ENGINE_OWNED_DIRS: &[&str] = &["queue", "tickets", "lessons", "hook-status"];
1111
1112/// Authority FILES that live directly under a `<repo>/.kranz` dir — the
1113/// same four [`authority_read_deny_paths`] names, factored out so the
1114/// container tier can apply them to the `.kranz` under its own session
1115/// mount without re-typing the list (2026-09-01 adversarial audit, MED-3:
1116/// the hand-copied three-name list had already drifted).
1117const KRANZ_AUTHORITY_FILES: &[&str] = &[
1118    "serve.token",
1119    "serve.read.token",
1120    "config.json",
1121    "domain-terms.local",
1122];
1123
1124/// The authority set under ONE `<repo>/.kranz` dir: the engine-owned files
1125/// and the engine-owned stores. Shared by the process, container, and
1126/// Windows tiers so none of them can drift from the others.
1127pub(crate) fn kranz_authority_entries(kranz_dir: &Path) -> WriteDenySet {
1128    WriteDenySet {
1129        files: KRANZ_AUTHORITY_FILES
1130            .iter()
1131            .map(|name| kranz_dir.join(name))
1132            .collect(),
1133        dirs: KRANZ_ENGINE_OWNED_DIRS
1134            .iter()
1135            .map(|name| kranz_dir.join(name))
1136            .collect(),
1137    }
1138}
1139
1140/// The `(<repo>/.kranz, <repo>/.kranz/missions, <mission dir>)` triples this
1141/// session's mission dir sits in — raw and canonical form — and ONLY when
1142/// the mission dir actually has the canonical `<repo>/.kranz/missions/<id>`
1143/// shape.
1144///
1145/// The shape check is load-bearing, not defensive tidiness: the deny
1146/// derivation below SWEEPS these directories, and a mission dir that is not
1147/// in the canonical layout (a bare temp dir in a fixture, a future layout
1148/// change) would otherwise make the sweep walk the system temp root, or
1149/// `/`, and deny writes across the whole host. A non-canonical layout
1150/// yields nothing here, which is the fail-quiet direction for a deny that is
1151/// additive to an already-narrow write allowlist.
1152fn repo_kranz_dirs(inputs: &SandboxInputs) -> Vec<(PathBuf, PathBuf, PathBuf)> {
1153    let mut out = Vec::new();
1154    for mission_dir in [inputs.mission_dir.clone(), absolutize(&inputs.mission_dir)] {
1155        let Some(missions_dir) = mission_dir.parent().map(Path::to_path_buf) else {
1156            continue;
1157        };
1158        let Some(kranz_dir) = missions_dir.parent().map(Path::to_path_buf) else {
1159            continue;
1160        };
1161        if missions_dir.file_name() != Some(std::ffi::OsStr::new("missions"))
1162            || kranz_dir.file_name() != Some(std::ffi::OsStr::new(".kranz"))
1163        {
1164            continue;
1165        }
1166        out.push((kranz_dir, missions_dir, mission_dir));
1167    }
1168    out
1169}
1170
1171/// A write-deny set split by what each tier can express: `files` deny as
1172/// Seatbelt literals / bwrap self ro-binds / Windows non-inheriting ACL
1173/// denies, `dirs` as Seatbelt subpaths / bwrap self ro-binds / inheriting
1174/// ACL denies.
1175pub(crate) struct WriteDenySet {
1176    pub files: Vec<PathBuf>,
1177    pub dirs: Vec<PathBuf>,
1178}
1179
1180fn sorted_dedup(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
1181    paths.sort();
1182    paths.dedup();
1183    paths
1184}
1185
1186/// Authority material a sandboxed session must never WRITE (2026-09-01
1187/// adversarial audit H2 + H11).
1188///
1189/// The pre-audit posture denied READS of `<repo>/.kranz/config.json`,
1190/// `serve.token`, `serve.read.token`, `domain-terms.local`, `hook-status/`
1191/// and the mission `control/` inbox while leaving them WRITABLE: under
1192/// `workerIsolation: "checkout"` the session cwd IS the repo root, so a
1193/// contained worker could overwrite the trust root it could not read. The
1194/// running mission is immune (its config is pinned into `MissionCreated`);
1195/// the next mission picks up `claudeBinary`, `dangerouslyAllowAll`, or
1196/// `enforce: off` from the file the worker wrote. Every path the read deny
1197/// names is therefore denied for writes as well.
1198///
1199/// H11 is the same asymmetry one level up: every mission-metadata deny was
1200/// derived from THIS session's `mission_dir`, so a mission-A worker could
1201/// forge approvals into mission B's `control/` inbox, or drop entries into
1202/// `.kranz/queue/`, `.kranz/tickets/`, `.kranz/lessons/`. The deny is now
1203/// derived from `<repo>/.kranz` as a whole:
1204///
1205/// - every ENGINE-owned repo-level store ([`KRANZ_ENGINE_OWNED_DIRS`]),
1206///   named unconditionally so a store that does not exist yet cannot be
1207///   created by a session either,
1208/// - every top-level entry of `<repo>/.kranz` present at profile-build time
1209///   EXCEPT `missions/`, which is carved out because the session's own
1210///   mission dir lives under it,
1211/// - every SIBLING mission dir under `<repo>/.kranz/missions/` — the
1212///   session's own mission dir is carved back in, and inside it the
1213///   narrower [`mission_write_denies`] keeps the audit log, state snapshot,
1214///   control inbox and transcripts read-only while leaving the session's
1215///   own worktree/scratch under `runs/` writable,
1216/// - the global key dir, via [`authority_read_deny_dirs`].
1217///
1218/// Enumeration is spawn-time, so an entry created under `<repo>/.kranz`
1219/// AFTER the profile is built is not individually named. Seatbelt closes
1220/// that residue with [`sealed_kranz_dir_roots`]; bwrap and Windows cannot
1221/// express it (their masks likewise require the target to exist at spawn),
1222/// which is the documented remainder on those tiers.
1223pub(crate) fn authority_write_denies(inputs: &SandboxInputs) -> WriteDenySet {
1224    let mut files = authority_read_deny_paths(inputs);
1225    let mut dirs = authority_read_deny_dirs(inputs);
1226    for (kranz_dir, missions_dir, mission_dir) in repo_kranz_dirs(inputs) {
1227        let entries = kranz_authority_entries(&kranz_dir);
1228        files.extend(entries.files);
1229        dirs.extend(entries.dirs);
1230        if let Ok(entries) = std::fs::read_dir(&kranz_dir) {
1231            for entry in entries.flatten() {
1232                // `missions/` is the one carve-out: the session's own
1233                // mission dir is under it (see the sibling sweep below).
1234                if entry.file_name().as_os_str() == std::ffi::OsStr::new("missions") {
1235                    continue;
1236                }
1237                let path = entry.path();
1238                match std::fs::symlink_metadata(&path) {
1239                    Ok(metadata) if metadata.file_type().is_dir() => dirs.push(path),
1240                    // A symlink is denied as a file: denying the LINK is
1241                    // what stops a session replacing it, and following it
1242                    // would deny some unrelated target instead.
1243                    Ok(_) => files.push(path),
1244                    Err(_) => {}
1245                }
1246            }
1247        }
1248        if let Ok(entries) = std::fs::read_dir(&missions_dir) {
1249            for entry in entries.flatten() {
1250                let path = entry.path();
1251                if path == mission_dir {
1252                    continue;
1253                }
1254                dirs.push(path);
1255            }
1256        }
1257    }
1258    WriteDenySet {
1259        files: sorted_dedup(files),
1260        dirs: sorted_dedup(dirs),
1261    }
1262}
1263
1264/// Seatbelt-only companion to [`authority_write_denies`]: directory roots
1265/// whose DIRECT children may not be created or replaced, emitted as a
1266/// `^<root>/[^/]*$` write-deny regex. Sealing `<repo>/.kranz` stops a
1267/// session creating `.kranz/queue/` (or any future engine store) after the
1268/// profile was built; sealing `<repo>/.kranz/missions` stops it fabricating
1269/// a sibling mission dir to forge approvals into. Neither seal reaches
1270/// GRANDchildren, so the session's own `<mission>/runs/<scratch>` stays
1271/// writable.
1272pub(crate) fn sealed_kranz_dir_roots(inputs: &SandboxInputs) -> Vec<PathBuf> {
1273    let mut roots = Vec::new();
1274    for (kranz_dir, missions_dir, _) in repo_kranz_dirs(inputs) {
1275        roots.push(kranz_dir);
1276        roots.push(missions_dir);
1277    }
1278    sorted_dedup(roots)
1279}
1280
1281/// The operator's OWN controlling terminal, in raw and canonical form
1282/// (2026-09-01 adversarial audit, H7).
1283///
1284/// The gate profile grants read/write/`file-ioctl` on the pty device class
1285/// `^/dev/tty[p-t][0-9a-f]+$` so the validator harness's `openpty` chain
1286/// works. On macOS that pool IS the terminal pool: a Terminal.app session is
1287/// `/dev/ttys003`, matched by the same regex. A contained gate command could
1288/// therefore open the operator's own terminal, write raw escape sequences to
1289/// it, or issue `TIOCSTI` to push characters into the operator's shell —
1290/// arbitrary execution as the operator, outside the sandbox. Naming the
1291/// parent's terminal explicitly lets both profiles DENY exactly that one
1292/// device while keeping the pty pair the harness allocates for itself; SBPL
1293/// denies beat allows regardless of clause order, which this file already
1294/// relies on throughout.
1295///
1296/// Resolved from the ENGINE's own fds 0/1/2 at profile-build time. Every
1297/// child the engine spawns gets piped or null stdio, so no sandboxed process
1298/// legitimately holds this device. An empty result (no tty at all: a daemon,
1299/// CI, `kranz serve`) emits nothing extra.
1300#[cfg(unix)]
1301pub(crate) fn operator_tty_paths() -> Vec<PathBuf> {
1302    let mut paths = Vec::new();
1303    for fd in [0, 1, 2] {
1304        // SAFETY: `isatty` and `ttyname_r` take a plain fd and, for the
1305        // latter, a caller-owned buffer with its length; no ownership
1306        // crosses. `ttyname_r` is the thread-safe form (`ttyname` returns a
1307        // shared static buffer).
1308        let name = unsafe {
1309            if libc::isatty(fd) != 1 {
1310                continue;
1311            }
1312            let mut buffer = [0 as libc::c_char; 1024];
1313            if libc::ttyname_r(fd, buffer.as_mut_ptr(), buffer.len()) != 0 {
1314                continue;
1315            }
1316            std::ffi::CStr::from_ptr(buffer.as_ptr())
1317                .to_string_lossy()
1318                .into_owned()
1319        };
1320        if name.is_empty() {
1321            continue;
1322        }
1323        let path = PathBuf::from(name);
1324        paths.push(absolutize(&path));
1325        paths.push(path);
1326    }
1327    sorted_dedup(paths)
1328}
1329
1330#[cfg(not(unix))]
1331pub(crate) fn operator_tty_paths() -> Vec<PathBuf> {
1332    Vec::new()
1333}
1334
1335/// Render the operator-terminal deny block for `paths` (2026-09-01
1336/// adversarial audit, H7). Empty in, empty out: a host with no controlling
1337/// terminal has nothing to protect, and an empty `(deny …)` block would be
1338/// noise in every CI profile. Shared by [`generate_profile`] and the gate
1339/// extras (`crate::command_exec::gate_profile_extras`) so the two cannot
1340/// render the same guard differently. Parameterized on the paths rather
1341/// than calling [`operator_tty_paths`] itself, so the rendering is testable
1342/// on a host whose test runner has no tty.
1343pub(crate) fn tty_deny_block(paths: &[PathBuf]) -> String {
1344    let literals: std::collections::BTreeSet<String> =
1345        paths.iter().map(|path| escape_sbpl_literal(path)).collect();
1346    if literals.is_empty() {
1347        return String::new();
1348    }
1349    let mut block = String::from("(deny file-read* file-write* file-ioctl\n");
1350    for literal in &literals {
1351        block.push_str(&format!("  (literal \"{literal}\")\n"));
1352    }
1353    block.push_str(")\n");
1354    block
1355}
1356
1357/// Refuse Git configurations whose complete input set this sandbox cannot
1358/// protect. The enforcement protects against contained children; a separate
1359/// unsandboxed host process can still change the repository concurrently.
1360pub(crate) fn validate_git_config_protection(
1361    inputs: &SandboxInputs,
1362    mount_based: bool,
1363) -> crate::error::Result<()> {
1364    let writable = write_allowlist(inputs);
1365    let neutral_config = absolutize(crate::git_ops::empty_global_config_path()?);
1366    if writable.iter().any(|root| neutral_config.starts_with(root)) {
1367        return Err(crate::error::EngineError::Backend(
1368            "cannot grant sandbox writes over the engine's neutral Git configuration; narrow the overlapping session, scratch, or extraWrite root".into(),
1369        ));
1370    }
1371    let Some(marker) = git_marker(&inputs.session_cwd) else {
1372        return Ok(());
1373    };
1374    let root = marker.parent().expect("git marker has a parent");
1375    let repo = crate::git_ops::GitRepo::open(root)?;
1376    let (git_dir, common, worktree_enabled) = repo.config_protection_paths()?;
1377    let described = git_metadata_dirs(&inputs.session_cwd);
1378    if [&git_dir, &common].iter().any(|dir| {
1379        !described
1380            .iter()
1381            .any(|path| absolutize(path) == absolutize(dir))
1382    }) {
1383        return Err(crate::error::EngineError::Backend(
1384            "cannot protect Git metadata redirected outside the session's Git layout; remove repository environment overrides before running an enforced session".into(),
1385        ));
1386    }
1387    let masks = authority_directory_masks(inputs);
1388    let mut graph_dirs = vec![git_dir.clone(), common.clone()];
1389    graph_dirs.extend(git_metadata_mount_nodes(inputs));
1390    if graph_dirs.iter().any(|dir| {
1391        let dir = absolutize(dir);
1392        // A shared Git directory outside writable roots remains read-only;
1393        // its existing read-only authority view needs no writable node bind.
1394        if !writable.iter().any(|root| dir.starts_with(root)) {
1395            return false;
1396        }
1397        masks.iter().any(|mask| {
1398            dir.starts_with(&mask.path)
1399                && ![&inputs.session_cwd, &inputs.tmpdir].iter().any(|private| {
1400                    let private = absolutize(private);
1401                    private != mask.path
1402                        && private.starts_with(&mask.path)
1403                        && dir.starts_with(private)
1404                })
1405        })
1406    }) {
1407        return Err(crate::error::EngineError::Backend(
1408            "cannot protect Git metadata through an authority directory; keep the Git directory outside .kranz and credential stores".into(),
1409        ));
1410    }
1411    let mut sources = vec![common.join("config")];
1412    if marker.is_file() {
1413        sources.push(marker.clone());
1414        sources.push(git_dir.join("commondir"));
1415    }
1416    if worktree_enabled {
1417        sources.push(git_dir.join("config.worktree"));
1418    }
1419    for source in sources {
1420        let writable_source = writable
1421            .iter()
1422            .any(|root| absolutize(&source).starts_with(root));
1423        // A symlink input (or replaceable symlink ancestor) defeats a path-only
1424        // deny. System aliases outside writable roots, such as /var, are fine.
1425        for ancestor in source.ancestors() {
1426            if std::fs::symlink_metadata(ancestor).is_ok_and(|meta| meta.file_type().is_symlink())
1427                && (ancestor == source
1428                    || writable.iter().any(|root| {
1429                        ancestor
1430                            .parent()
1431                            .map(absolutize)
1432                            .map(|parent| parent.join(ancestor.file_name().unwrap_or_default()))
1433                            .is_some_and(|path| path.starts_with(root))
1434                    }))
1435            {
1436                return Err(crate::error::EngineError::Backend(format!(
1437                    "cannot protect Git configuration through symlink {}; use regular Git metadata paths", ancestor.display()
1438                )));
1439            }
1440        }
1441        match std::fs::symlink_metadata(&source) {
1442            Ok(meta) if meta.is_file() => {
1443                #[cfg(unix)]
1444                {
1445                    use std::os::unix::fs::MetadataExt;
1446                    if meta.nlink() != 1 {
1447                        return Err(crate::error::EngineError::Backend(format!(
1448                            "cannot protect multiply linked Git configuration {}; replace it with a private regular file", source.display()
1449                        )));
1450                    }
1451                }
1452            }
1453            Err(error)
1454                if error.kind() == std::io::ErrorKind::NotFound
1455                    && (!mount_based || !writable_source) => {}
1456            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1457                return Err(crate::error::EngineError::Backend(format!(
1458                    "cannot protect absent active Git configuration {} with a mount sandbox; create the intended regular config file before running, or disable extensions.worktreeConfig", source.display()
1459                )));
1460            }
1461            _ => {
1462                return Err(crate::error::EngineError::Backend(format!(
1463                    "cannot protect Git configuration {}; expected a regular file",
1464                    source.display()
1465                )))
1466            }
1467        }
1468    }
1469    Ok(())
1470}
1471
1472fn git_marker(cwd: &Path) -> Option<PathBuf> {
1473    cwd.ancestors()
1474        .map(|path| path.join(".git"))
1475        .find(|path| std::fs::symlink_metadata(path).is_ok())
1476}
1477
1478/// Follow only the two bounded Git indirection files to describe denies. The
1479/// execution boundary validates the layout using Git itself before spawning.
1480fn git_metadata_dirs(cwd: &Path) -> Vec<PathBuf> {
1481    let Some(marker) = git_marker(cwd) else {
1482        return vec![cwd.join(".git")];
1483    };
1484    let read = |path: &Path| {
1485        std::fs::File::open(path)
1486            .ok()
1487            .and_then(|file| crate::paths::read_regular_file_bounded(file, 16384).ok())
1488    };
1489    if marker.is_dir() {
1490        return vec![marker];
1491    }
1492    let Some(link) = read(&marker) else {
1493        return Vec::new();
1494    };
1495    let Some(path) = link.trim().strip_prefix("gitdir: ") else {
1496        return Vec::new();
1497    };
1498    let dir = absolutize(&marker.parent().unwrap().join(path));
1499    let mut dirs = vec![dir.clone()];
1500    if let Some(common) = read(&dir.join("commondir")) {
1501        dirs.push(absolutize(&dir.join(common.trim())));
1502    }
1503    dirs
1504}
1505
1506/// Pin writable metadata directory nodes as mountpoints, so renaming a parent
1507/// cannot replace a protected config path. The directories stay writable for
1508/// Git's index/ref lock files; named configuration inputs are mounted read-only.
1509pub(crate) fn git_metadata_mount_nodes(inputs: &SandboxInputs) -> Vec<PathBuf> {
1510    let writable = write_allowlist(inputs);
1511    let mut nodes = std::collections::BTreeSet::new();
1512    let mut dirs = git_metadata_dirs(&inputs.session_cwd);
1513    if let Some(marker) = git_marker(&inputs.session_cwd).filter(|path| path.is_file()) {
1514        dirs.push(
1515            marker
1516                .parent()
1517                .expect("git marker has a parent")
1518                .to_path_buf(),
1519        );
1520    }
1521    for dir in dirs {
1522        for path in absolutize(&dir).ancestors() {
1523            if writable
1524                .iter()
1525                .any(|root| path.starts_with(root) && path != root)
1526                && path.is_dir()
1527            {
1528                nodes.insert(path.to_path_buf());
1529            }
1530        }
1531    }
1532    nodes.into_iter().collect()
1533}
1534
1535/// The `.git` metadata a sandboxed session must never write (2026-09-01
1536/// adversarial audit H3 support).
1537///
1538/// The engine runs git IN the tree the worker controls: `commit_dirty_paths`
1539/// checkpoints after every feature on a hardened handle
1540/// (`orchestrator.rs`), and `push_mission_branch` pushes from the CLI. A
1541/// worker that plants `.git/hooks/pre-commit`, sets `core.fsmonitor` /
1542/// `core.sshCommand` in `.git/config`, or rewrites the WORKTREE GITLINK to
1543/// point at a `.git` dir of its own making gets host execution with the
1544/// engine's full environment, outside every sandbox.
1545///
1546/// The deny is NARROW on purpose: the worker's own role is to commit (see
1547/// `prompts/worker.md` step 6), so `.git/index`, `.git/objects`,
1548/// `.git/refs`, and `.git/logs` must stay writable. What is denied is the
1549/// config-and-hook surface that turns a later engine-side git invocation
1550/// into code execution, plus the gitlink FILE form:
1551///
1552/// - `<cwd>/.git/hooks/` and `<cwd>/.git/info/` (subtrees),
1553/// - `<cwd>/.git/modules/` (subtree) — a submodule keeps its own `config`
1554///   and `hooks/` under `.git/modules/<name>/`, the SAME host-execution
1555///   surface one level down, and in checkout mode it sits inside the rw
1556///   session bind (follow-up review, M-9). Denied as a whole subtree so a
1557///   submodule added after the profile was built is covered too; git never
1558///   needs to write it from inside the sandbox.
1559/// - `<cwd>/.git/config` and `<cwd>/.git/config.worktree` (files),
1560/// - `<cwd>/.git` itself as a LITERAL — in worktree mode that path is the
1561///   gitlink file, and denying the literal stops a rewrite of it; in
1562///   checkout mode it is the directory node, where the literal deny stops a
1563///   replace of the directory without touching anything beneath it.
1564///
1565/// Reads stay allowed throughout: git cannot operate without reading its
1566/// own config, and secrecy was never this tier's promise.
1567pub(crate) fn git_metadata_write_denies(inputs: &SandboxInputs) -> WriteDenySet {
1568    let mut files = git_metadata_mount_nodes(inputs);
1569    let mut dirs = Vec::new();
1570    for cwd in [inputs.session_cwd.clone(), absolutize(&inputs.session_cwd)] {
1571        files.push(cwd.join(".git"));
1572        if let Some(marker) = git_marker(&cwd) {
1573            files.push(marker);
1574        }
1575        for git in git_metadata_dirs(&cwd) {
1576            files.push(git.join("config"));
1577            files.push(git.join("config.worktree"));
1578            files.push(git.join("commondir"));
1579            dirs.push(git.join("hooks"));
1580            dirs.push(git.join("info"));
1581            dirs.push(git.join("modules"));
1582        }
1583    }
1584    WriteDenySet {
1585        files: sorted_dedup(files),
1586        dirs: sorted_dedup(dirs),
1587    }
1588}
1589
1590/// One entry of the validator read-deny set: a top-level path of a real
1591/// checkout root the validator must not read, classified so the Seatbelt
1592/// profile can pick `subpath` vs `literal` and the bwrap argv can pick a
1593/// tmpfs shadow vs a `/dev/null` mask.
1594#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1595pub(crate) struct ValidatorReadDenyEntry {
1596    pub path: PathBuf,
1597    pub is_dir: bool,
1598}
1599
1600/// Top-level names a validator read-deny root ALWAYS keeps readable, because
1601/// the validator's own machinery cannot work without them:
1602///
1603/// - `.git` — the shared git directory. The snapshot is a WORKTREE: its
1604///   `.git` file points into `<root>/.git/worktrees/<n>`, and every
1605///   `git log`/`diff`/`show` the scrutiny/inspection flow runs resolves
1606///   objects and refs through the common dir. This is the narrow
1607///   `.git` surface the ticket keeps: READABLE (the fold needs it), never
1608///   writable (deny-default; a ref move is the tripwire's `for-each-ref`
1609///   half). `.git/config` stays readable for the same reason git itself
1610///   reads it — the same posture today's broad-read sandbox has.
1611/// - `.kranz` — the mission dir lives here, and the validator's snapshot
1612///   worktree sits under it (`<root>/.kranz/missions/<id>/runs/`). The
1613///   engine-owned metadata inside stays write-denied
1614///   ([`mission_write_denies`]) and the authority files read-denied
1615///   ([`authority_read_deny_paths`]) exactly as for any session; the rest
1616///   (tracked `workspace.json`, tickets) is content the snapshot already
1617///   carries.
1618const VALIDATOR_READ_DENY_CARVEOUTS: &[&str] = &[".git", ".kranz"];
1619
1620/// The validator read-deny set (ticket `validator-mandatory-containment`):
1621/// every TOP-LEVEL entry of each [`SandboxInputs::validator_read_deny_roots`]
1622/// root EXCEPT the [`VALIDATOR_READ_DENY_CARVEOUTS`]. Denying whole top-level
1623/// entries covers the source tree without naming the root itself as a
1624/// subpath (which would swallow the carved-out `.git`/`.kranz` beneath it —
1625/// SBPL denies take precedence over every allow, so no allow could carve
1626/// them back out).
1627///
1628/// Entries are classified by `std::fs::metadata` — which FOLLOWS symlinks —
1629/// so a symlinked top-level dir is denied as a dir (and the canonical form
1630/// emitted alongside covers the link TARGET, the same raw+canonical idiom
1631/// [`authority_read_deny_paths`] uses; Seatbelt matches canonical paths).
1632/// Entries whose metadata fails (a broken symlink, a racer's unlink) are
1633/// skipped: a dangling link leaks nothing, and a vanished entry is gone.
1634///
1635/// The root ITSELF is not in this set: a literal deny on the root dir would
1636/// block stat/readdir of it, and coreutils `mkdir -p` stats every ancestor
1637/// of an absolute path — denying the root broke `mkdir -p` under the
1638/// snapshot (probed 2026-08-04). The root's directory LISTING therefore
1639/// stays readable on both tiers (names, never contents — the bwrap side
1640/// cannot express a listing deny without masking the carve-outs anyway).
1641///
1642/// One documented residual gap, outside the threat model (validator code can
1643/// create NOTHING at a deny root — writes there are deny-default): an entry
1644/// created at a root AFTER profile generation is not in the set — the same
1645/// spawn-time shape the bwrap authority masks already accept.
1646pub(crate) fn validator_read_deny_entries(inputs: &SandboxInputs) -> Vec<ValidatorReadDenyEntry> {
1647    let mut entries = std::collections::BTreeSet::new();
1648    for root in &inputs.validator_read_deny_roots {
1649        let Ok(read_dir) = std::fs::read_dir(root) else {
1650            continue;
1651        };
1652        for entry in read_dir.flatten() {
1653            let name = entry.file_name();
1654            if VALIDATOR_READ_DENY_CARVEOUTS.contains(&name.to_string_lossy().as_ref()) {
1655                continue;
1656            }
1657            let path = entry.path();
1658            let Ok(metadata) = std::fs::metadata(&path) else {
1659                continue;
1660            };
1661            let is_dir = metadata.is_dir();
1662            entries.insert(ValidatorReadDenyEntry {
1663                path: path.clone(),
1664                is_dir,
1665            });
1666            let canonical = absolutize(&path);
1667            if canonical != path {
1668                entries.insert(ValidatorReadDenyEntry {
1669                    path: canonical,
1670                    is_dir,
1671                });
1672            }
1673        }
1674    }
1675    entries.into_iter().collect()
1676}
1677
1678/// Default Anthropic egress plus mission-configured additions, trimmed and
1679/// de-duplicated in stable order.
1680pub fn effective_egress(configured: &[String]) -> Vec<String> {
1681    let mut out: Vec<String> = DEFAULT_EGRESS.iter().map(|s| (*s).to_string()).collect();
1682    for item in configured {
1683        let item = item.trim();
1684        if !item.is_empty() && !out.iter().any(|existing| existing == item) {
1685            out.push(item.to_string());
1686        }
1687    }
1688    out
1689}
1690
1691/// Generate an SBPL profile: deny-by-default, broad read (Seatbelt cannot
1692/// usefully scope toolchain/dyld reads without breaking `/bin/sh`) with the
1693/// [`authority_read_deny_paths`]/[`authority_read_deny_dirs`] carve-out,
1694/// write limited to subpaths of
1695/// `session_cwd`, the session-private scratch `tmpdir`, and each
1696/// `extra_write` entry — with the mission metadata of
1697/// [`mission_write_denies`] carved back OUT by explicit write denies, so the
1698/// audit log / state snapshot / control inbox / transcripts stay read-only
1699/// to the session even in checkout mode (where `session_cwd` is the repo
1700/// root and the mission dir sits under it). `fs` allows network — the profile wraps the agent
1701/// binary itself, so denying egress bricks Anthropic/API sessions; write
1702/// containment is the fs-tier promise. `fs+net` restricts outbound TCP to
1703/// loopback: Seatbelt rejects hostname egress rules (`host must be * or
1704/// localhost`), so the per-host allowlist is enforced by the run's egress
1705/// proxy (`crate::egress_proxy`) — the only reachable way out. The
1706/// operator's real Cargo registry/git caches carry an explicit write deny
1707/// of their own (13th-pass review, P1 — [`cargo_cache_write_deny_paths`]):
1708/// the isolated contract home may LINK them in above the copy ceiling, and
1709/// the linked target must stay read-only under every allow.
1710///
1711/// Mandatory validator containment (ticket `validator-mandatory-containment`):
1712/// when [`SandboxInputs::validator_read_deny_roots`] is non-empty (validator
1713/// sessions only), a second read-deny block closes the broad read allow over
1714/// the REAL checkout's source tree — every top-level entry of each root
1715/// except the `.git`/`.kranz` carve-outs ([`validator_read_deny_entries`]) —
1716/// plus the `/dev/null` write allow every shell/git needs under deny-default
1717/// (the gate wrap's documented finding). The root's own listing stays
1718/// readable (names, never contents — a literal deny on the root breaks
1719/// `mkdir -p` under the snapshot, which stats every ancestor; the bwrap
1720/// side cannot express the listing deny at all). The snapshot worktree
1721/// (under `<root>/.kranz/...`) and the shared git dir stay readable; the
1722/// validator provably reads only its snapshot's contents. Denies take
1723/// precedence over the broad allow regardless of clause order (the same
1724/// guarantee the authority deny above relies on). Every Seatbelt session
1725/// also keeps `/dev/null` writable: shells, Git, and agent tool runners use
1726/// it for ordinary redirects even outside validator sessions.
1727pub fn generate_profile(inputs: &SandboxInputs) -> String {
1728    let write_paths = write_allowlist(inputs);
1729
1730    let mut profile = String::new();
1731    profile.push_str("(version 1)\n");
1732    profile.push_str("(deny default)\n");
1733    profile.push('\n');
1734    profile.push_str("(allow process*)\n");
1735    profile.push_str("(allow signal (target self))\n");
1736    profile.push_str("(allow sysctl-read)\n");
1737    profile.push_str("(allow mach-lookup)\n");
1738    profile.push_str("(allow mach-register)\n");
1739    profile.push_str("(allow iokit-open)\n");
1740    profile.push('\n');
1741    // Reads stay broad: Seatbelt cannot usefully express "toolchain + dyld +
1742    // locale" without a long allowlist that still breaks `/bin/sh` redirects.
1743    // Secrecy is not the fs-tier promise — write containment is — with ONE
1744    // carve-out: the authority material below.
1745    profile.push_str("(allow file-read*)\n");
1746    profile.push('\n');
1747    // Serve tokens, the repo config, the plaintext lint vocabulary, the
1748    // hook-status projection, and the control inbox must stay unreadable even
1749    // under the broad read allow (see authority_read_deny_paths /
1750    // authority_read_deny_dirs). SBPL denies take precedence over allows
1751    // regardless of clause order (verified with sandbox-exec), so placing
1752    // the deny after the allow is documentary.
1753    let mut deny_literals = std::collections::BTreeSet::new();
1754    for path in authority_read_deny_paths(inputs) {
1755        deny_literals.insert(escape_sbpl_literal(&path));
1756    }
1757    let mut deny_subpaths = std::collections::BTreeSet::new();
1758    for dir in authority_read_deny_dirs(inputs) {
1759        deny_subpaths.insert(escape_sbpl_literal(&dir));
1760    }
1761    if !deny_literals.is_empty() || !deny_subpaths.is_empty() {
1762        profile.push_str("(deny file-read*\n");
1763        for lit in &deny_subpaths {
1764            profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1765        }
1766        for lit in &deny_literals {
1767            profile.push_str(&format!("  (literal \"{lit}\")\n"));
1768        }
1769        profile.push_str(")\n");
1770        profile.push('\n');
1771    }
1772    // Mandatory validator containment (see the fn doc): read-deny the real
1773    // checkout's source tree. Directories deny as subpaths (the whole
1774    // subtree), files as literals. Deny wins over the broad read allow
1775    // regardless of clause order — placement after it is documentary. The
1776    // root ITSELF is deliberately NOT denied: a literal deny on the root
1777    // dir blocks stat/readdir of it, and coreutils `mkdir -p` stats every
1778    // ancestor of an absolute path — denying the root broke `mkdir -p`
1779    // under the snapshot (probed 2026-08-04). The root's directory LISTING
1780    // stays visible (names, never contents) — the same posture the bwrap
1781    // side is limited to anyway.
1782    let validator_denies = validator_read_deny_entries(inputs);
1783    if !validator_denies.is_empty() {
1784        let mut subpaths = std::collections::BTreeSet::new();
1785        let mut literals = std::collections::BTreeSet::new();
1786        for entry in &validator_denies {
1787            if entry.is_dir {
1788                subpaths.insert(escape_sbpl_literal(&entry.path));
1789            } else {
1790                literals.insert(escape_sbpl_literal(&entry.path));
1791            }
1792        }
1793        profile.push_str("(deny file-read*\n");
1794        for lit in &subpaths {
1795            profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1796        }
1797        for lit in &literals {
1798            profile.push_str(&format!("  (literal \"{lit}\")\n"));
1799        }
1800        profile.push_str(")\n");
1801        profile.push('\n');
1802    }
1803    // `/dev/null` must stay writable even under deny-default (the gate
1804    // wrap's documented finding, `gate_profile_extras` — probed
1805    // 2026-08-03): Git, shells, and agent tool runners open it O_RDWR in
1806    // ordinary operation. This applies to every session, not only the
1807    // validator shape above.
1808    profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
1809    profile.push('\n');
1810    match inputs.enforce {
1811        crate::types::SandboxEnforce::FsNet => {
1812            // Loopback-only egress: the session's proxy hops (CONNECT to
1813            // 127.0.0.1) are legal, and every non-localhost destination is
1814            // denied here at the kernel boundary — the egress proxy is the
1815            // only way out and applies the hostname allowlist.
1816            profile.push_str("(allow network-outbound (remote tcp \"localhost:*\"))\n");
1817        }
1818        // `fs` (and Off) must allow network: this profile wraps the agent
1819        // binary, so `deny network*` bricks API egress. Egress restriction
1820        // is an `fs+net` concern.
1821        crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::Off => {
1822            profile.push_str("(allow network*)\n");
1823        }
1824    }
1825    profile.push('\n');
1826    // Write allowlist: include both the canonical path and the path as given
1827    // (macOS `/var` ↔ `/private/var`) so shell redirects using either form match.
1828    profile.push_str("(allow file-write*\n");
1829    let mut write_literals = std::collections::BTreeSet::new();
1830    for p in &write_paths {
1831        write_literals.insert(escape_sbpl_literal(p));
1832    }
1833    for raw in [&inputs.session_cwd, &inputs.tmpdir]
1834        .into_iter()
1835        .chain(inputs.extra_write.iter())
1836    {
1837        write_literals.insert(escape_sbpl_literal(raw));
1838        write_literals.insert(escape_sbpl_literal(&absolutize(raw)));
1839    }
1840    for lit in &write_literals {
1841        profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1842    }
1843    profile.push_str(")\n");
1844    profile.push('\n');
1845    // Mission metadata write deny: the allowlist no longer names the mission
1846    // dir, but in checkout mode `session_cwd` IS the repo root and the
1847    // mission dir sits under it — without these denies the audit log, state
1848    // snapshot, control inbox, and transcripts would be writable through the
1849    // session-cwd subpath allow. SBPL denies take precedence over allows
1850    // regardless of clause order (the same guarantee the read deny above
1851    // relies on), so placement after the allow is documentary.
1852    let write_denies = mission_write_denies(inputs);
1853    profile.push_str("(deny file-write*\n");
1854    let mut deny_literals = std::collections::BTreeSet::new();
1855    for p in &write_denies.files {
1856        deny_literals.insert(escape_sbpl_literal(p));
1857    }
1858    for lit in &deny_literals {
1859        profile.push_str(&format!("  (literal \"{lit}\")\n"));
1860    }
1861    let mut deny_subpaths = std::collections::BTreeSet::new();
1862    for p in &write_denies.control_dirs {
1863        deny_subpaths.insert(escape_sbpl_literal(p));
1864    }
1865    for lit in &deny_subpaths {
1866        profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1867    }
1868    // Transcripts are `runs/*.jsonl` files directly under the runs dir; the
1869    // `[^/]*` keeps `runs/` SUBDIRECTORIES (session scratch, preflight
1870    // worktree) writable.
1871    let mut deny_regexes = std::collections::BTreeSet::new();
1872    for p in &write_denies.runs_dirs {
1873        deny_regexes.insert(escape_sbpl_regex(p));
1874    }
1875    for lit in &deny_regexes {
1876        profile.push_str(&format!("  (regex #\"^{lit}/[^/]*\\.jsonl$\")\n"));
1877    }
1878    profile.push_str(")\n");
1879    profile.push('\n');
1880
1881    // Authority write deny (2026-09-01 adversarial audit, H2 + H11 — see
1882    // authority_write_denies for the full why): every path the read deny
1883    // above names, plus the repo-level `.kranz` stores and every SIBLING
1884    // mission dir. Under checkout mode `session_cwd` is the repo root, so
1885    // without this block a contained worker could overwrite the trust root
1886    // it cannot read, or forge approvals into another mission's inbox.
1887    // Denies take precedence over allows regardless of clause order, so
1888    // placement after the write allow is documentary.
1889    let authority_writes = authority_write_denies(inputs);
1890    let mut authority_write_literals = std::collections::BTreeSet::new();
1891    for path in &authority_writes.files {
1892        authority_write_literals.insert(escape_sbpl_literal(path));
1893    }
1894    let mut authority_write_subpaths = std::collections::BTreeSet::new();
1895    for path in &authority_writes.dirs {
1896        authority_write_subpaths.insert(escape_sbpl_literal(path));
1897    }
1898    // The spawn-time residue the enumeration cannot cover: sealing the
1899    // DIRECT children of `<repo>/.kranz` and `<repo>/.kranz/missions` stops
1900    // a session creating a store or a sibling mission dir after the profile
1901    // was built. `[^/]*` never crosses a separator, so the session's own
1902    // `<mission>/runs/<scratch>` stays writable.
1903    let mut sealed_regexes = std::collections::BTreeSet::new();
1904    for root in sealed_kranz_dir_roots(inputs) {
1905        sealed_regexes.insert(escape_sbpl_regex(&root));
1906    }
1907    if !authority_write_literals.is_empty()
1908        || !authority_write_subpaths.is_empty()
1909        || !sealed_regexes.is_empty()
1910    {
1911        profile.push_str("(deny file-write*\n");
1912        for lit in &authority_write_subpaths {
1913            profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1914        }
1915        for lit in &authority_write_literals {
1916            profile.push_str(&format!("  (literal \"{lit}\")\n"));
1917        }
1918        for root in &sealed_regexes {
1919            profile.push_str(&format!("  (regex #\"^{root}/[^/]*$\")\n"));
1920        }
1921        profile.push_str(")\n");
1922        profile.push('\n');
1923    }
1924
1925    // `.git` metadata write deny (2026-09-01 adversarial audit, H3 support —
1926    // see git_metadata_write_denies): the engine checkpoints with a
1927    // hardened git handle in the tree the worker controls, so the hook and
1928    // config surface that turns the next engine-side `git commit` into host
1929    // execution is denied. Narrow by design — the worker's own role is to
1930    // commit, so the index, objects, refs and logs stay writable.
1931    let git_writes = git_metadata_write_denies(inputs);
1932    let mut git_write_literals = std::collections::BTreeSet::new();
1933    for path in &git_writes.files {
1934        git_write_literals.insert(escape_sbpl_literal(path));
1935    }
1936    let mut git_write_subpaths = std::collections::BTreeSet::new();
1937    for path in &git_writes.dirs {
1938        git_write_subpaths.insert(escape_sbpl_literal(path));
1939    }
1940    if !git_write_literals.is_empty() || !git_write_subpaths.is_empty() {
1941        profile.push_str("(deny file-write*\n");
1942        for lit in &git_write_subpaths {
1943            profile.push_str(&format!("  (subpath \"{lit}\")\n"));
1944        }
1945        for lit in &git_write_literals {
1946            profile.push_str(&format!("  (literal \"{lit}\")\n"));
1947        }
1948        profile.push_str(")\n");
1949        profile.push('\n');
1950    }
1951
1952    // Operator terminal deny (2026-09-01 adversarial audit, H7 — see
1953    // operator_tty_paths): the engine's own controlling terminal is denied
1954    // read, write, AND ioctl. Every child the engine spawns has piped or
1955    // null stdio, so nothing inside the sandbox needs this device, and the
1956    // deny is what stops escape-sequence writes and TIOCSTI-class input
1957    // injection into the operator's shell. Nothing is emitted when the
1958    // engine has no terminal (a daemon, CI, `kranz serve`).
1959    let tty_block = tty_deny_block(&operator_tty_paths());
1960    if !tty_block.is_empty() {
1961        profile.push_str(&tty_block);
1962        profile.push('\n');
1963    }
1964
1965    profile.push_str("(deny file-write*\n");
1966    // Match future sibling missions too: enumeration alone would leave an
1967    // inbox created after this session starts writable under checkout mode.
1968    if let Some(missions) = inputs
1969        .mission_dir
1970        .parent()
1971        .filter(|path| path.ends_with("missions"))
1972    {
1973        for root in [missions.to_path_buf(), absolutize(missions)] {
1974            let root = escape_sbpl_regex(&root);
1975            profile.push_str(&format!(
1976                "  (regex #\"^{root}/[^/]+/(events\\.jsonl(\\.lock)?|state\\.json(\\.tmp)?|estimate\\.json)$\")\n"
1977            ));
1978            profile.push_str(&format!("  (regex #\"^{root}/[^/]+/control(/|$)\")\n"));
1979            profile.push_str(&format!(
1980                "  (regex #\"^{root}/[^/]+/runs/[^/]*\\.jsonl$\")\n"
1981            ));
1982        }
1983    }
1984    profile.push_str(")\n");
1985
1986    // Denying a file alone does not stop renaming its parent directory,
1987    // which would move authority outside path-based read/write rules.
1988    // Pin ancestor names without denying ordinary writes to their children.
1989    let mut pinned_dirs = std::collections::BTreeSet::new();
1990    for path in authority_read_deny_paths(inputs)
1991        .into_iter()
1992        .chain(authority_read_deny_dirs(inputs))
1993        .chain(mission_write_denies(inputs).control_dirs)
1994    {
1995        for parent in path.ancestors().skip(1) {
1996            pinned_dirs.insert(escape_sbpl_literal(parent));
1997        }
1998    }
1999    profile.push_str("(deny file-write-unlink\n");
2000    for path in pinned_dirs {
2001        profile.push_str(&format!("  (literal \"{path}\")\n"));
2002    }
2003    if let Some(missions) = inputs
2004        .mission_dir
2005        .parent()
2006        .filter(|p| p.ends_with("missions"))
2007    {
2008        for path in [missions.to_path_buf(), absolutize(missions)] {
2009            profile.push_str(&format!(
2010                "  (regex #\"^{}/[^/]+(/(control|runs))?$\")\n",
2011                escape_sbpl_regex(&path)
2012            ));
2013        }
2014    }
2015    profile.push_str(")\n");
2016
2017    // Shared-Cargo-cache write deny (13th-pass review, P1 — see
2018    // cargo_cache_write_deny_paths for the full why): when the shared
2019    // registry/git cache exceeds the copy ceiling, the isolated contract
2020    // home LINKS it in, and only an EXPLICIT deny keeps the link target
2021    // read-only under every allow (an operator extraWrite of $HOME would
2022    // otherwise re-widen it to worker-authored contract code). Precise
2023    // scope: registry/ and git/ only. Denies take precedence over allows
2024    // regardless of clause order (the same guarantee the blocks above
2025    // rely on), so placement after the allow is documentary.
2026    let mut cache_denies = std::collections::BTreeSet::new();
2027    for path in cargo_cache_write_deny_paths() {
2028        cache_denies.insert(escape_sbpl_literal(&path));
2029    }
2030    if !cache_denies.is_empty() {
2031        profile.push_str("(deny file-write*\n");
2032        for lit in &cache_denies {
2033            profile.push_str(&format!("  (subpath \"{lit}\")\n"));
2034        }
2035        profile.push_str(")\n");
2036    }
2037
2038    profile
2039}
2040
2041/// Build the bubblewrap argv tail for running `binary args` under the resolved
2042/// sandbox. The caller uses program `bwrap` and passes this vector as args.
2043///
2044/// Write scope mirrors the Seatbelt profile: the whole filesystem is bound
2045/// read-only, then `session_cwd`, the session-private scratch `tmpdir`, and
2046/// each `extra_write` entry are bound writable — the mission dir and the
2047/// shared system temp root are NOT writable (ticket sandbox-writable-scope).
2048/// Mission metadata that an rw ancestor bind would otherwise cover (checkout
2049/// mode) is masked back out, the bwrap analogue of the profile's write deny;
2050/// the operator's real Cargo registry/git caches get explicit stacked
2051/// ro-binds for the same reason (13th-pass review — they stay readable, a
2052/// linked cache is the session's registry, but never writable).
2053///
2054/// Mandatory validator containment (ticket `validator-mandatory-containment`
2055/// — the bwrap analogue of the profile's validator read-deny block): when
2056/// [`SandboxInputs::validator_read_deny_roots`] is non-empty, each top-level
2057/// source-tree entry of those roots ([`validator_read_deny_entries`]) is
2058/// masked — directories shadowed by an empty tmpfs, files by a `/dev/null`
2059/// ro-bind — so the whole-fs ro-bind no longer exposes the real checkout's
2060/// contents. The `.git`/`.kranz` carve-outs stay (git needs the shared
2061/// object store; the snapshot lives under `.kranz`). The root's own
2062/// directory LISTING stays visible on both tiers (names, never contents):
2063/// bwrap cannot close it without masking the carve-outs, and the Seatbelt
2064/// side declines to (a literal deny on the root breaks `mkdir -p` under
2065/// the snapshot). `/dev/null` needs no allow here — the bwrap argv mounts
2066/// a real `/dev` (`--dev /dev`).
2067pub fn bubblewrap_args(
2068    inputs: &SandboxInputs,
2069    binary: &Path,
2070    args: &[String],
2071) -> crate::error::Result<Vec<String>> {
2072    let mut out = vec![
2073        "--die-with-parent".to_string(),
2074        "--ro-bind".to_string(),
2075        "/".to_string(),
2076        "/".to_string(),
2077        "--dev".to_string(),
2078        "/dev".to_string(),
2079        "--proc".to_string(),
2080        "/proc".to_string(),
2081        // Namespace set (2026-09-01 adversarial audit, H8). Before it the
2082        // argv unshared ONLY the network namespace, and only under `fs+net`:
2083        //
2084        // - `--unshare-pid` is what makes `--proc /proc` mean what the mount
2085        //   above assumes. Without it the contained agent sees HOST procfs
2086        //   and can read `/proc/<engine pid>/environ` — precisely the set
2087        //   `agent_env`'s env_clear exists to keep away from a
2088        //   prompt-injectable child — on any host with
2089        //   `kernel.yama.ptrace_scope = 0`. It also stops the agent
2090        //   signalling the engine or any same-uid host process.
2091        // - `--unshare-ipc` closes the System V / POSIX IPC channel to host
2092        //   processes.
2093        // - `--unshare-uts` and `--unshare-cgroup-try` keep hostname and
2094        //   cgroup views from being host-identifying or host-mutable. The
2095        //   `-try` suffix is load-bearing (follow-up review, M-8): cgroup
2096        //   namespaces need Linux >= 4.6 and are unavailable in some nested
2097        //   container and hardened-kernel environments, where the plain
2098        //   `--unshare-cgroup` makes bwrap EXIT non-zero — and every resolver
2099        //   in this file fails closed, so the whole session would die rather
2100        //   than degrade by one namespace. `--unshare-pid`/`ipc`/`uts` are
2101        //   long-supported and stay unconditional.
2102        // - `--new-session` drops the controlling terminal, which is the
2103        //   Linux half of the TIOCSTI escape H7 names on macOS (still live
2104        //   on kernels built with CONFIG_LEGACY_TIOCSTI). Safe here: every
2105        //   child the engine spawns gets piped or null stdio, and the pty
2106        //   harness passes its OWN slave fd rather than relying on an
2107        //   inherited ctty.
2108        //
2109        // Unconditional, unlike `--unshare-net` below: none of these is an
2110        // egress decision, and `fs` is a containment tier too.
2111        "--unshare-pid".to_string(),
2112        "--unshare-ipc".to_string(),
2113        "--unshare-uts".to_string(),
2114        "--unshare-cgroup-try".to_string(),
2115        "--new-session".to_string(),
2116    ];
2117    if inputs.enforce == crate::types::SandboxEnforce::FsNet {
2118        out.push("--unshare-net".to_string());
2119    }
2120    for path in write_allowlist(inputs) {
2121        let path = path.display().to_string();
2122        out.push("--bind".to_string());
2123        out.push(path.clone());
2124        out.push(path);
2125    }
2126    // Keep the mission namespace read-only, including siblings created
2127    // after spawn. A validator snapshot or private scratch nested here
2128    // gets its own writable bind back before the metadata masks below.
2129    if let Some(missions) = inputs
2130        .mission_dir
2131        .parent()
2132        .filter(|path| path.ends_with("missions") && path.is_dir())
2133    {
2134        let missions = absolutize(missions);
2135        let display = missions.display().to_string();
2136        out.extend(["--ro-bind".to_string(), display.clone(), display]);
2137        for private in [&inputs.session_cwd, &inputs.tmpdir] {
2138            let private = absolutize(private);
2139            if private.starts_with(&missions) && private != missions {
2140                let display = private.display().to_string();
2141                out.extend(["--bind".to_string(), display.clone(), display]);
2142            }
2143        }
2144    }
2145    // The bwrap analogue of the profile's shared-Cargo-cache write deny
2146    // (13th-pass review, P1 — cargo_cache_write_deny_paths): the `/`
2147    // ro-bind already mounts the real caches read-only, but an rw bind
2148    // covering an ancestor (an extraWrite of $HOME) would silently
2149    // re-widen them — stack an explicit ro-bind OVER each cache dir
2150    // present at spawn (later binds win; the destination must exist,
2151    // hence the is_dir filter). The dir stays READABLE — a linked cache
2152    // is the session/gate's registry — only writes close. This
2153    // behavior is also enforced by the private Cargo-home namespace below,
2154    // which keeps later credential/cache creation out of the session.
2155    let cache_ro_binds: std::collections::BTreeSet<String> = cargo_cache_write_deny_paths()
2156        .iter()
2157        .filter(|path| path.is_dir())
2158        .map(|path| path.display().to_string())
2159        .collect();
2160    for bind in &cache_ro_binds {
2161        out.push("--ro-bind".to_string());
2162        out.push(bind.clone());
2163        out.push(bind.clone());
2164    }
2165    // The bwrap analogue of the profile's authority and `.git` write denies
2166    // (2026-09-01 adversarial audit, H2 + H11 + H3 support): bwrap has no
2167    // per-path write deny to stack over an rw bind, so each denied path is
2168    // ro-bound OVER ITSELF — the contents stay READABLE (git cannot run
2169    // without its own config, and the Seatbelt side denies writes only) while
2170    // every write closes. Later binds win: install these after the initial
2171    // writable roots, and restore them after any private-root rebind below.
2172    //
2173    // Private authority directory views below close both reads and writes,
2174    // including authority files created after launch. These extra ro-binds
2175    // cover readable policy and Git metadata outside those views. For that
2176    // Git configuration surface, active absent inputs are refused before
2177    // spawn. Inactive config.worktree cannot become active while the main
2178    // config is immutable. Writable self-binds pin metadata directory nodes
2179    // against rename without closing the index/object/ref lockfile paths.
2180    // Do not directly bind READ-denied paths: their private directory view
2181    // owns the destination. A host bind stacked over a mask would restore
2182    // the credential the mask is meant to hide.
2183    // The write-deny set is a superset of the read-deny set by
2184    // construction, so subtract the masked paths and everything beneath a
2185    // masked directory before binding.
2186    let masked_files: Vec<PathBuf> = authority_read_deny_paths(inputs)
2187        .iter()
2188        .map(|p| lexical_absolute(p))
2189        .collect();
2190    let masked_dirs: Vec<PathBuf> = authority_read_deny_dirs(inputs)
2191        .iter()
2192        .map(|p| lexical_absolute(p))
2193        .collect();
2194    let is_masked = |path: &Path| -> bool {
2195        let abs = lexical_absolute(path);
2196        masked_files.contains(&abs) || masked_dirs.iter().any(|d| abs.starts_with(d))
2197    };
2198    let git_mount_nodes = git_metadata_mount_nodes(inputs);
2199    for node in &git_mount_nodes {
2200        let node = node.display().to_string();
2201        out.extend(["--bind".to_string(), node.clone(), node]);
2202    }
2203    let authority_writes = authority_write_denies(inputs);
2204    let git_writes = git_metadata_write_denies(inputs);
2205    let write_ro_binds: std::collections::BTreeSet<String> = authority_writes
2206        .files
2207        .iter()
2208        .chain(authority_writes.dirs.iter())
2209        .filter(|path| path.exists())
2210        .chain(
2211            git_writes
2212                .files
2213                .iter()
2214                .filter(|path| path.is_file() && !path.is_symlink()),
2215        )
2216        .chain(git_writes.dirs.iter().filter(|path| path.is_dir()))
2217        .filter(|path| !is_masked(path))
2218        .map(|path| lexical_absolute(path).display().to_string())
2219        .collect();
2220    for bind in &write_ro_binds {
2221        out.push("--ro-bind".to_string());
2222        out.push(bind.clone());
2223        out.push(bind.clone());
2224    }
2225    // Reject hostile metadata leaves before constructing read-only views.
2226    // Missing state/transcript files stay absent in the private namespace;
2227    // no host-side state.json.tmp placeholder is needed.
2228    let write_denies = mission_write_denies(inputs);
2229    for path in &write_denies.files {
2230        match std::fs::symlink_metadata(path) {
2231            Ok(metadata) if metadata.file_type().is_file() => {}
2232            Ok(_) => {
2233                return Err(crate::error::EngineError::InvalidState(format!(
2234                    "bwrap mask prep: {} exists and is not a regular file",
2235                    path.display()
2236                )))
2237            }
2238            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2239            Err(error) => return Err(error.into()),
2240        }
2241    }
2242    let authority_dirs: Vec<_> = authority_read_deny_dirs(inputs)
2243        .iter()
2244        .map(|path| absolutize(path))
2245        .collect();
2246    let authority_masks = authority_directory_masks(inputs);
2247    for mask in &authority_masks {
2248        let display = mask.path.display().to_string();
2249        out.extend(["--tmpfs".to_string(), display.clone()]);
2250        for path in &mask.visible_entries {
2251            let path = path.display().to_string();
2252            // Ordinary entries can disappear after enumeration (for example,
2253            // another gate's temporary cache). Leaving a missing entry hidden
2254            // is safe; the enclosing mask and read-only remount remain required.
2255            out.extend(["--ro-bind-try".to_string(), path.clone(), path]);
2256        }
2257        // A deeper mask may replace an entry hidden by this one (e.g.
2258        // HOME/.kranz below a HOME mask for a missing Cargo home). Reserve
2259        // its empty mountpoint in the private tmpfs before sealing it.
2260        for nested in &authority_masks {
2261            if nested.path != mask.path
2262                && nested.path.starts_with(&mask.path)
2263                && !mask
2264                    .visible_entries
2265                    .iter()
2266                    .any(|entry| nested.path.starts_with(entry))
2267            {
2268                out.extend(["--dir".to_string(), nested.path.display().to_string()]);
2269            }
2270        }
2271        out.extend(["--remount-ro".to_string(), display]);
2272        // A snapshot or session-private scratch under .kranz remains writable.
2273        // Later (deeper) masks still hide its own authority directories.
2274        for private in [&inputs.session_cwd, &inputs.tmpdir] {
2275            let private = absolutize(private);
2276            if private != mask.path
2277                && private.starts_with(&mask.path)
2278                && !authority_dirs
2279                    .iter()
2280                    .any(|denied| private.starts_with(denied))
2281            {
2282                let path = private.display().to_string();
2283                out.extend(["--bind".to_string(), path.clone(), path.clone()]);
2284                for node in git_mount_nodes
2285                    .iter()
2286                    .filter(|node| node.starts_with(&private))
2287                {
2288                    let node = node.display().to_string();
2289                    out.extend(["--bind".to_string(), node.clone(), node]);
2290                }
2291                // Host-source binds replace every nested mount. Restore the
2292                // write denies this private root just covered, before deeper
2293                // authority masks hide their secrets. Rebinding denied host
2294                // directories after those masks would expose them again.
2295                let restored_denies: std::collections::BTreeSet<_> = cache_ro_binds
2296                    .iter()
2297                    .chain(&write_ro_binds)
2298                    .filter_map(|denied| {
2299                        let denied_path = Path::new(denied);
2300                        if denied_path.starts_with(&private) {
2301                            Some(denied)
2302                        } else if private.starts_with(denied_path) {
2303                            Some(&path)
2304                        } else {
2305                            None
2306                        }
2307                    })
2308                    .collect();
2309                for denied in restored_denies {
2310                    out.extend(["--ro-bind".to_string(), denied.clone(), denied.clone()]);
2311                }
2312            }
2313        }
2314    }
2315    // The bwrap analogue of the profile's validator read-deny block (ticket
2316    // validator-mandatory-containment — see the fn doc): shadow each real
2317    // source-tree entry so the whole-fs ro-bind stops exposing it. Dirs get
2318    // an empty tmpfs (the existing control/-shadow idiom), files a
2319    // /dev/null ro-bind (the authority-mask idiom). Entries were enumerated
2320    // from the live fs and exist at spawn; later binds win, and this block
2321    // lands after every bind and authority view, so neither can re-expose a
2322    // denied entry — the carve-outs (`.git`, `.kranz`) were never in the
2323    // set, so the snapshot and the shared git dir stay as their binds left
2324    // them.
2325    for entry in validator_read_deny_entries(inputs) {
2326        let display = entry.path.display().to_string();
2327        if entry.is_dir {
2328            out.push("--tmpfs".to_string());
2329            out.push(display);
2330        } else {
2331            out.push("--ro-bind".to_string());
2332            out.push("/dev/null".to_string());
2333            out.push(display);
2334        }
2335    }
2336    out.push("--chdir".to_string());
2337    out.push(absolutize(&inputs.session_cwd).display().to_string());
2338    out.push("--".to_string());
2339    out.push(binary.display().to_string());
2340    out.extend(args.iter().cloned());
2341    Ok(out)
2342}
2343
2344/// Write the profile to a uniquely-named file under `dir`, returning its path.
2345pub fn write_profile_file(dir: &Path, profile: &str) -> std::io::Result<PathBuf> {
2346    std::fs::create_dir_all(dir)?;
2347    let path = dir.join(format!("kranz-sandbox-{}.sb", uuid::Uuid::new_v4()));
2348    std::fs::write(&path, profile)?;
2349    Ok(path)
2350}
2351
2352#[cfg(test)]
2353mod tests {
2354    use super::*;
2355
2356    #[cfg(target_os = "macos")]
2357    use std::sync::Mutex;
2358
2359    /// `extra_write: ["~/cache"]` must resolve against the platform home.
2360    /// Regression: this read `HOME` only, which a natively launched
2361    /// `kranz.exe` does not have (Git Bash injects one; cmd/PowerShell/
2362    /// Explorer do not), so the entry stayed the literal `~/cache` and the
2363    /// sandbox grant silently targeted a directory named `~`.
2364    #[test]
2365    fn expand_tilde_uses_the_platform_home_variable() {
2366        assert_eq!(
2367            expand_tilde("relative/path"),
2368            PathBuf::from("relative/path")
2369        );
2370        assert_eq!(expand_tilde("~notatilde"), PathBuf::from("~notatilde"));
2371
2372        let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
2373            .expect("the platform home variable is always set on a real host");
2374        let expanded = expand_tilde("~/cache");
2375        assert_eq!(expanded, PathBuf::from(&home).join("cache"));
2376        assert!(
2377            expanded.is_absolute(),
2378            "an expanded home path must be absolute: {expanded:?}"
2379        );
2380
2381        #[cfg(windows)]
2382        assert_eq!(expand_tilde(r"~\cache"), PathBuf::from(&home).join("cache"));
2383    }
2384
2385    #[cfg(target_os = "macos")]
2386    static SANDBOX_EXEC_TEST_LOCK: Mutex<()> = Mutex::new(());
2387
2388    #[cfg(target_os = "macos")]
2389    fn sandbox_exec_can_apply() -> bool {
2390        let found = std::process::Command::new("which")
2391            .arg("sandbox-exec")
2392            .output()
2393            .map(|o| o.status.success())
2394            .unwrap_or(false);
2395        if !found {
2396            crate::test_capability::skip(
2397                crate::test_capability::capability::SANDBOX_EXEC,
2398                "sandbox-exec not found on this host",
2399            );
2400            return false;
2401        }
2402
2403        let smoke = std::process::Command::new("sandbox-exec")
2404            .arg("-p")
2405            .arg("(version 1)\n(allow default)\n")
2406            .arg("/usr/bin/true")
2407            .output();
2408        match smoke {
2409            Ok(output) if output.status.success() => true,
2410            Ok(output) => {
2411                eprintln!(
2412                    "sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
2413                    String::from_utf8_lossy(&output.stderr)
2414                );
2415                false
2416            }
2417            Err(e) => {
2418                eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
2419                false
2420            }
2421        }
2422    }
2423
2424    #[cfg(target_os = "linux")]
2425    fn bwrap_can_apply() -> bool {
2426        if !command_available("bwrap") {
2427            crate::test_capability::skip(
2428                crate::test_capability::capability::BWRAP,
2429                "bwrap not found on this host",
2430            );
2431            return false;
2432        }
2433
2434        let smoke = std::process::Command::new("bwrap")
2435            .args([
2436                "--die-with-parent",
2437                "--ro-bind",
2438                "/",
2439                "/",
2440                "--dev",
2441                "/dev",
2442                "--proc",
2443                "/proc",
2444                "--",
2445                "/bin/true",
2446            ])
2447            .output();
2448        match smoke {
2449            Ok(output) if output.status.success() => true,
2450            Ok(output) => {
2451                eprintln!(
2452                    "bwrap cannot apply a smoke sandbox on this host; skipping: {}",
2453                    String::from_utf8_lossy(&output.stderr)
2454                );
2455                false
2456            }
2457            Err(e) => {
2458                eprintln!("bwrap smoke probe failed; skipping: {e}");
2459                false
2460            }
2461        }
2462    }
2463
2464    fn inputs(
2465        session_cwd: &Path,
2466        mission_dir: &Path,
2467        tmpdir: &Path,
2468        extra: Vec<PathBuf>,
2469    ) -> SandboxInputs {
2470        SandboxInputs {
2471            enforce: crate::types::SandboxEnforce::Fs,
2472            session_cwd: session_cwd.to_path_buf(),
2473            mission_dir: mission_dir.to_path_buf(),
2474            tmpdir: tmpdir.to_path_buf(),
2475            extra_write: extra,
2476            egress: Vec::new(),
2477            validator_read_deny_roots: Vec::new(),
2478        }
2479    }
2480
2481    #[test]
2482    fn sandbox_profile_contains_required_clauses() {
2483        let session = tempfile::tempdir().unwrap();
2484        let mission = tempfile::tempdir().unwrap();
2485        let tmp = tempfile::tempdir().unwrap();
2486        let extra = tempfile::tempdir().unwrap();
2487
2488        let profile = generate_profile(&inputs(
2489            session.path(),
2490            mission.path(),
2491            tmp.path(),
2492            vec![extra.path().to_path_buf()],
2493        ));
2494
2495        assert!(profile.contains("(version 1)"));
2496        assert!(profile.contains("(deny default)"));
2497        assert!(profile.contains("(allow file-read*)"));
2498        assert!(profile.contains("(allow file-write* (literal \"/dev/null\"))"));
2499        // `fs` must allow network so the sandboxed agent can reach its API.
2500        assert!(profile.contains("(allow network*)"));
2501        assert!(!profile.contains("(deny network*)"));
2502
2503        let session_abs = absolutize(session.path());
2504        let tmp_abs = absolutize(tmp.path());
2505        let extra_abs = absolutize(extra.path());
2506
2507        // The writable set: session cwd, the session-private scratch, and
2508        // each extraWrite entry.
2509        for p in [&session_abs, &tmp_abs, &extra_abs] {
2510            let expected = format!("(subpath \"{}\")", escape_sbpl_literal(p));
2511            assert!(
2512                profile.contains(&expected),
2513                "profile missing subpath rule for {:?}:\n{}",
2514                p,
2515                profile
2516            );
2517        }
2518
2519        // The mission dir is NOT writable (its engine-owned metadata carries
2520        // explicit write denies instead — see
2521        // sandbox_profile_denies_mission_metadata_writes).
2522        let mission_abs = absolutize(mission.path());
2523        let mission_rule = format!("(subpath \"{}\")", escape_sbpl_literal(&mission_abs));
2524        assert!(
2525            !profile.contains(&mission_rule),
2526            "profile must not allow writes to the whole mission dir:\n{profile}"
2527        );
2528    }
2529
2530    #[test]
2531    fn sandbox_profile_denies_authority_material_reads() {
2532        let _env = crate::agent_env::EnvTestGuard::engage(&[]);
2533        let repo = tempfile::tempdir().unwrap();
2534        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2535        std::fs::create_dir_all(&mission).unwrap();
2536        let tmp = tempfile::tempdir().unwrap();
2537
2538        let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
2539
2540        // Broad reads stay, with the authority material carved out by explicit
2541        // denies (SBPL denies take precedence over the allow).
2542        assert!(profile.contains("(allow file-read*)"));
2543        assert!(profile.contains("(deny file-read*"));
2544        let kranz_dir = repo.path().join(".kranz");
2545        for name in [
2546            "serve.token",
2547            "serve.read.token",
2548            "config.json",
2549            // The plaintext clean-room lint vocabulary (14th-pass review).
2550            "domain-terms.local",
2551        ] {
2552            for base in [kranz_dir.clone(), absolutize(&kranz_dir)] {
2553                let expected = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(name)));
2554                assert!(
2555                    profile.contains(&expected),
2556                    "profile missing read deny for {}:\n{profile}",
2557                    base.join(name).display()
2558                );
2559            }
2560        }
2561        // The cargo registry token file is denied too (CARGO_HOME crosses
2562        // into child envs for cache locality; its credentials must not
2563        // ride along).
2564        assert!(
2565            profile.contains("credentials.toml"),
2566            "profile missing read deny for cargo credentials:\n{profile}"
2567        );
2568        if let Some(global) = crate::paths::global_config() {
2569            let global_dir = global.parent().unwrap();
2570            for operation in ["(deny file-read*", "(deny file-write*"] {
2571                for protected in [&global, global_dir] {
2572                    assert!(
2573                        profile.split(operation).skip(1).any(|block| {
2574                            block
2575                                .split("\n)\n")
2576                                .next()
2577                                .unwrap()
2578                                .contains(&escape_sbpl_literal(protected))
2579                        }),
2580                        "missing {operation} rule for {}",
2581                        protected.display()
2582                    );
2583                }
2584            }
2585        }
2586    }
2587
2588    #[cfg(target_os = "macos")]
2589    #[test]
2590    fn sandbox_checkout_denies_authority_writes_and_future_sibling_inboxes() {
2591        use std::process::Command;
2592        let root = tempfile::tempdir().unwrap();
2593        let root = root.path().canonicalize().unwrap();
2594        let mission = root.join(".kranz/missions/m-current");
2595        std::fs::create_dir_all(&mission).unwrap();
2596        let config = root.join(".kranz/config.json");
2597        let config_target = root.join("operator-config.json");
2598        std::fs::write(&config_target, "original").unwrap();
2599        std::os::unix::fs::symlink(&config_target, &config).unwrap();
2600        let scratch = root.join("scratch");
2601        std::fs::create_dir(&scratch).unwrap();
2602        let profile = generate_profile(&inputs(&root, &mission, &scratch, vec![]));
2603        // Create a sibling after generating the profile to cover future
2604        // missions rather than relying on a spawn-time directory listing.
2605        let sibling = root.join(".kranz/missions/m-later/control");
2606        std::fs::create_dir_all(&sibling).unwrap();
2607        let run = |script: &str, path: &Path| {
2608            Command::new("sandbox-exec")
2609                .args(["-p", &profile, "/bin/sh", "-c", script, "audit"])
2610                .arg(path)
2611                .output()
2612                .unwrap()
2613        };
2614        let probe = run("exit 0", &root);
2615        if !probe.status.success()
2616            && String::from_utf8_lossy(&probe.stderr).contains("sandbox_apply")
2617        {
2618            eprintln!("SKIP-UNDER-WRAP: nested sandbox unavailable");
2619            return;
2620        }
2621        assert!(probe.status.success(), "{probe:?}");
2622        for protected in [
2623            config.clone(),
2624            config_target.clone(),
2625            sibling.join("forged.json"),
2626            sibling.parent().unwrap().join("events.jsonl"),
2627        ] {
2628            assert!(
2629                !run("printf forged > \"$1\"", &protected).status.success(),
2630                "wrote {}",
2631                protected.display()
2632            );
2633        }
2634        assert_eq!(std::fs::read_to_string(config).unwrap(), "original");
2635        assert!(
2636            !run("cat \"$1\"", &config_target).status.success(),
2637            "the symlink's target must carry the same authority read denial"
2638        );
2639        assert!(!sibling.join("forged.json").exists());
2640        assert!(
2641            !run(
2642                "ln \"$1/.kranz/config.json\" \"$1/authority-hardlink\"",
2643                &root
2644            )
2645            .status
2646            .success(),
2647            "a hard link must not move authority outside the read deny"
2648        );
2649        assert!(
2650            !run("mv \"$1/.kranz\" \"$1/moved-authority\"", &root)
2651                .status
2652                .success(),
2653            "renaming the authority parent must not move it outside its deny rules"
2654        );
2655        assert!(run("printf feature > \"$1\"", &root.join("feature.txt"))
2656            .status
2657            .success());
2658        assert!(run("printf scratch > \"$1\"", &scratch.join("work.txt"))
2659            .status
2660            .success());
2661    }
2662
2663    #[cfg(target_os = "macos")]
2664    #[test]
2665    fn sandbox_global_authority_is_denied_when_created_after_profile() {
2666        if crate::agent_env::isolated_global_home_test(
2667            "sandbox::tests::sandbox_global_authority_is_denied_when_created_after_profile",
2668        ) {
2669            return;
2670        }
2671        let dir = tempfile::tempdir().unwrap();
2672        let root = dir.path().canonicalize().unwrap();
2673        let home = root.join("operator");
2674        let alias = root.join("operator-alias");
2675        std::fs::create_dir(&home).unwrap();
2676        std::os::unix::fs::symlink(&home, &alias).unwrap();
2677        let session = root.join("session");
2678        let mission = session.join(".kranz/missions/m-test");
2679        let scratch = root.join("scratch");
2680        std::fs::create_dir_all(&mission).unwrap();
2681        std::fs::create_dir(&scratch).unwrap();
2682        let profile = {
2683            let _env = crate::agent_env::EnvTestGuard::engage(&[("HOME", alias.to_str().unwrap())]);
2684            generate_profile(&inputs(&session, &mission, &scratch, vec![home.clone()]))
2685        };
2686        let authority = home.join(".kranz/serve/later.token");
2687        std::fs::create_dir_all(authority.parent().unwrap()).unwrap();
2688        std::fs::write(&authority, "fake-authority").unwrap();
2689        let output = std::process::Command::new("sandbox-exec")
2690            .args(["-p", &profile, "/bin/sh", "-c",
2691                "printf witness > \"$1/witness\" || exit 2; if cat \"$2\"; then exit 3; fi; if printf forged > \"$2\"; then exit 4; fi",
2692                "test"])
2693            .arg(&session).arg(&authority).output().unwrap();
2694        if String::from_utf8_lossy(&output.stderr).contains("sandbox_apply") {
2695            eprintln!("SKIP-UNDER-WRAP: nested sandbox unavailable");
2696            return;
2697        }
2698        assert!(output.status.success(), "{output:?}");
2699        assert!(session.join("witness").exists());
2700        assert_eq!(
2701            std::fs::read_to_string(authority).unwrap(),
2702            "fake-authority"
2703        );
2704    }
2705
2706    /// Composition audit (ticket `config-fail-open-audit`): the effective
2707    /// egress list EXTENDS the compiled-in Anthropic floor — a mission's
2708    /// configured `egress[]` (and, downstream, its operator-approved egress
2709    /// grants) can only add destinations, never drop or narrow the defaults.
2710    /// A replace-shaped regression here strands the sandboxed session's own
2711    /// API access, or worse, goes unnoticed while the operator believes the
2712    /// floor is still composed in.
2713    #[test]
2714    fn composition_audit_effective_egress_extends_never_replaces_the_default_floor() {
2715        let configured = vec![
2716            " crates.io:443 ".to_string(),       // trimmed on the way in
2717            "api.anthropic.com:443".to_string(), // a duplicate of the floor
2718            "registry.npmjs.org:443".to_string(),
2719        ];
2720        let out = effective_egress(&configured);
2721        assert_eq!(
2722            out,
2723            vec![
2724                "api.anthropic.com:443".to_string(),
2725                "*.anthropic.com:443".to_string(),
2726                "crates.io:443".to_string(),
2727                "registry.npmjs.org:443".to_string(),
2728            ]
2729        );
2730        // An empty configured list still yields the full default floor.
2731        assert_eq!(effective_egress(&[]).len(), DEFAULT_EGRESS.len());
2732    }
2733
2734    /// Composition audit: `extraWrite` EXTENDS the writable floor (session
2735    /// cwd + session-private scratch) — the floor itself is not configurable
2736    /// away, so no config shape can un-write the session's own worktree or
2737    /// its private scratch.
2738    #[test]
2739    fn composition_audit_extra_write_extends_never_replaces_the_writable_floor() {
2740        let session = tempfile::tempdir().unwrap();
2741        let mission = tempfile::tempdir().unwrap();
2742        let tmp = tempfile::tempdir().unwrap();
2743        let extra = tempfile::tempdir().unwrap();
2744        let inputs = inputs(
2745            session.path(),
2746            mission.path(),
2747            tmp.path(),
2748            vec![extra.path().to_path_buf()],
2749        );
2750        let writable = write_allowlist(&inputs);
2751        for floor in [absolutize(session.path()), absolutize(tmp.path())] {
2752            assert!(
2753                writable.contains(&floor),
2754                "the writable floor {floor:?} must survive any extraWrite list"
2755            );
2756        }
2757        assert!(writable.contains(&absolutize(extra.path())));
2758    }
2759
2760    /// Composition audit: the explicit deny sets (mission metadata writes,
2761    /// authority reads) survive an `extraWrite` broad enough to COVER them.
2762    /// SBPL denies take precedence over every allow regardless of clause
2763    /// order, so the deny clauses must still be emitted when the allow side
2764    /// is at its widest — this is the deny-wins pin for the sandbox surface.
2765    #[test]
2766    fn composition_audit_explicit_denies_survive_a_covering_extra_write_allow() {
2767        let repo = tempfile::tempdir().unwrap();
2768        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2769        std::fs::create_dir_all(&mission).unwrap();
2770        let tmp = tempfile::tempdir().unwrap();
2771        // extraWrite = the repo root: every mission file now sits under an
2772        // allowed subpath — the widest realistic allow shape.
2773        let profile = generate_profile(&inputs(
2774            repo.path(),
2775            &mission,
2776            tmp.path(),
2777            vec![repo.path().to_path_buf()],
2778        ));
2779        // The covering allow IS emitted...
2780        assert!(
2781            profile.contains(&format!(
2782                "(subpath \"{}\")",
2783                escape_sbpl_literal(&absolutize(repo.path()))
2784            )),
2785            "the covering extraWrite allow must be present:\n{profile}"
2786        );
2787        // ...and the metadata write denies still are too: the audit log,
2788        // state snapshot, and control inbox stay unwritable through the
2789        // allow because SBPL denies win over it.
2790        assert!(profile.contains("(deny file-write*"));
2791        for name in ["events.jsonl", "state.json"] {
2792            assert!(
2793                profile.contains(&escape_sbpl_literal(&mission.join(name))),
2794                "the write deny for {name} must survive the covering allow:\n{profile}"
2795            );
2796        }
2797        // Authority reads (serve.token) stay denied under the broad read
2798        // allow for the same reason.
2799        assert!(
2800            profile.contains(&escape_sbpl_literal(
2801                &repo.path().join(".kranz").join("serve.token")
2802            )),
2803            "the read deny for serve.token must survive the covering allow:\n{profile}"
2804        );
2805    }
2806
2807    #[test]
2808    fn bubblewrap_args_mask_authority_material_with_dev_null() {
2809        let repo = tempfile::tempdir().unwrap();
2810        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2811        std::fs::create_dir_all(&mission).unwrap();
2812        let tmp = tempfile::tempdir().unwrap();
2813        let serve_token = repo.path().join(".kranz").join("serve.token");
2814        std::fs::write(&serve_token, "secret").unwrap();
2815
2816        let args = bubblewrap_args(
2817            &inputs(repo.path(), &mission, tmp.path(), vec![]),
2818            Path::new("/usr/bin/claude"),
2819            &[],
2820        )
2821        .unwrap();
2822        let joined = args.join(" ");
2823
2824        let expected = format!(
2825            "--tmpfs {}",
2826            absolutize(serve_token.parent().unwrap()).display()
2827        );
2828        assert!(
2829            joined.contains(&expected),
2830            "missing private authority directory: {args:?}"
2831        );
2832        // Neither existing nor future credentials get a bind back into it.
2833        assert!(!joined.contains("serve.token"));
2834        assert!(
2835            !joined.contains("serve.read.token"),
2836            "future authority files must not get a host bind: {args:?}"
2837        );
2838    }
2839
2840    #[test]
2841    fn sandbox_profile_denies_mission_metadata_writes() {
2842        let repo = tempfile::tempdir().unwrap();
2843        let mission = repo.path().join(".kranz").join("missions").join("m-x");
2844        std::fs::create_dir_all(&mission).unwrap();
2845        let scratch = tempfile::tempdir().unwrap();
2846
2847        // Checkout-mode shape: the session cwd is the repo root, an ANCESTOR
2848        // of the mission dir — without explicit write denies the audit log,
2849        // state snapshot, control inbox, and transcripts would be writable
2850        // through the session-cwd subpath allow.
2851        let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
2852
2853        assert!(
2854            profile.contains("(deny file-write*"),
2855            "missing write deny block:\n{profile}"
2856        );
2857        for name in MISSION_METADATA_FILES {
2858            for base in [mission.clone(), absolutize(&mission)] {
2859                let expected = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(name)));
2860                assert!(
2861                    profile.contains(&expected),
2862                    "profile missing write deny for {}:\n{profile}",
2863                    base.join(name).display()
2864                );
2865            }
2866        }
2867        for base in [mission.clone(), absolutize(&mission)] {
2868            let control = format!(
2869                "(subpath \"{}\")",
2870                escape_sbpl_literal(&base.join("control"))
2871            );
2872            assert!(
2873                profile.contains(&control),
2874                "profile missing control/ write deny:\n{profile}"
2875            );
2876            let runs = format!(
2877                "(regex #\"^{}/[^/]*\\.jsonl$\")",
2878                escape_sbpl_regex(&base.join("runs"))
2879            );
2880            assert!(
2881                profile.contains(&runs),
2882                "profile missing transcript write deny:\n{profile}"
2883            );
2884        }
2885        // The mission dir root itself is not in the allow set.
2886        let mission_rule = format!(
2887            "(subpath \"{}\")",
2888            escape_sbpl_literal(&absolutize(&mission))
2889        );
2890        assert!(
2891            !profile.contains(&mission_rule),
2892            "the whole mission dir must not be writable:\n{profile}"
2893        );
2894    }
2895
2896    /// 13th-pass review (P1): above the copy ceiling the isolated contract
2897    /// Cargo home LINKS the operator's registry/git caches in — the profile
2898    /// must deny writes to those REAL cache dirs (raw AND canonical forms)
2899    /// so the linked target stays read-only under every allow. The deny is
2900    /// PRECISE: the two cache dirs, never the whole cargo home (rustup/cargo
2901    /// binaries keep their ordinary posture).
2902    #[test]
2903    fn cache_write_deny_profile_denies_real_cache_dirs_precisely() {
2904        let cargo = tempfile::tempdir().unwrap();
2905        std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
2906        std::fs::create_dir_all(cargo.path().join("git")).unwrap();
2907        let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2908            "CARGO_HOME",
2909            cargo.path().to_str().expect("utf-8 temp path"),
2910        )]);
2911        let session = tempfile::tempdir().unwrap();
2912        let mission = tempfile::tempdir().unwrap();
2913        let scratch = tempfile::tempdir().unwrap();
2914
2915        let profile = generate_profile(&inputs(
2916            session.path(),
2917            mission.path(),
2918            scratch.path(),
2919            vec![],
2920        ));
2921        for base in [cargo.path().to_path_buf(), absolutize(cargo.path())] {
2922            for name in ["registry", "git"] {
2923                let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&base.join(name)));
2924                assert!(
2925                    profile.contains(&expected),
2926                    "profile missing cache write deny for {}:\n{profile}",
2927                    base.join(name).display()
2928                );
2929            }
2930        }
2931        // Precision: the cargo home ITSELF is not in the deny set — the
2932        // closing `"` after the home path makes this an exact-line check
2933        // (the registry/git lines carry a longer path and cannot match).
2934        for base in [cargo.path().to_path_buf(), absolutize(cargo.path())] {
2935            let whole_home = format!("(subpath \"{}\")", escape_sbpl_literal(&base));
2936            assert!(
2937                !profile.contains(&whole_home),
2938                "the deny must be precise to the cache dirs, not the whole cargo home:\n{profile}"
2939            );
2940        }
2941    }
2942
2943    /// The bwrap analogue: the real cache dirs present at spawn get explicit
2944    /// ro-binds stacked AFTER the rw binds (later binds win — an rw
2945    /// extraWrite covering an ancestor must not re-widen them), and absent
2946    /// dirs are skipped (bwrap requires the destination to exist).
2947    #[test]
2948    fn cache_write_deny_bwrap_stacks_ro_binds_over_real_cache() {
2949        let cargo = tempfile::tempdir().unwrap();
2950        std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
2951        // git/ deliberately absent → not bound (the is_dir filter).
2952        let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2953            "CARGO_HOME",
2954            cargo.path().to_str().expect("utf-8 temp path"),
2955        )]);
2956        let session = tempfile::tempdir().unwrap();
2957        let mission = tempfile::tempdir().unwrap();
2958        let scratch = tempfile::tempdir().unwrap();
2959
2960        let args = bubblewrap_args(
2961            &inputs(session.path(), mission.path(), scratch.path(), vec![]),
2962            Path::new("/usr/bin/claude"),
2963            &[],
2964        )
2965        .unwrap();
2966        let joined = args.join(" ");
2967
2968        let registry = absolutize(&cargo.path().join("registry"));
2969        let expected = format!("--ro-bind {0} {0}", registry.display());
2970        assert!(
2971            joined.contains(&expected),
2972            "missing stacked ro-bind for the real registry cache: {args:?}"
2973        );
2974        let git_cache = cargo.path().join("git");
2975        assert!(
2976            !joined.contains(&git_cache.display().to_string()),
2977            "an absent cache dir must not be bound: {args:?}"
2978        );
2979        // Ordering is load-bearing: the cache ro-bind must land AFTER every
2980        // rw `--bind`, or a wide writable root would re-cover it.
2981        let last_rw = args
2982            .iter()
2983            .rposition(|arg| arg == "--bind")
2984            .expect("the writable roots are rw-bound");
2985        let registry_arg = registry.display().to_string();
2986        let cache_pos = args
2987            .windows(3)
2988            .position(|w| w[0] == "--ro-bind" && w[1] == registry_arg && w[2] == registry_arg)
2989            .expect("the cache ro-bind pair exists");
2990        assert!(
2991            cache_pos > last_rw,
2992            "the cache ro-bind must stack after the rw binds: {args:?}"
2993        );
2994    }
2995
2996    #[test]
2997    fn sandbox_profile_keeps_sibling_temp_neighbors_unwritable() {
2998        // The worktree-mode layout the finding named: integration/feature
2999        // worktrees for ALL missions sit side by side under the shared temp
3000        // root. The session's own worktree + private scratch must be
3001        // writable; the sibling mission's worktree, the sibling's scratch,
3002        // and the shared temp root itself must not.
3003        let root = tempfile::tempdir().unwrap();
3004        let session = root.path().join("kranz-wt-aaa-m1-f-1-1");
3005        let scratch = root.path().join("kranz-worker-home-sess-1");
3006        let sibling = root.path().join("kranz-wt-bbb-m2-_integration");
3007        let sibling_scratch = root.path().join("kranz-worker-home-sess-2");
3008        for d in [&session, &scratch, &sibling, &sibling_scratch] {
3009            std::fs::create_dir_all(d).unwrap();
3010        }
3011        let mission = tempfile::tempdir().unwrap();
3012
3013        let profile = generate_profile(&inputs(&session, mission.path(), &scratch, vec![]));
3014
3015        for allowed in [&session, &scratch] {
3016            let expected = format!(
3017                "(subpath \"{}\")",
3018                escape_sbpl_literal(&absolutize(allowed))
3019            );
3020            assert!(
3021                profile.contains(&expected),
3022                "profile missing allow for {}:\n{profile}",
3023                allowed.display()
3024            );
3025        }
3026        for denied in [&sibling, &sibling_scratch, &root.path().to_path_buf()] {
3027            let rule = format!("(subpath \"{}\")", escape_sbpl_literal(&absolutize(denied)));
3028            assert!(
3029                !profile.contains(&rule),
3030                "{} must not be writable:\n{profile}",
3031                denied.display()
3032            );
3033        }
3034    }
3035
3036    #[test]
3037    fn bubblewrap_args_mask_mission_metadata() {
3038        let repo = tempfile::tempdir().unwrap();
3039        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3040        let runs = mission.join("runs");
3041        std::fs::create_dir_all(&runs).unwrap();
3042        let scratch = tempfile::tempdir().unwrap();
3043        // Engine-owned metadata present at spawn.
3044        let events = mission.join("events.jsonl");
3045        let state = mission.join("state.json");
3046        let transcript = runs.join("run-1.jsonl");
3047        let denials = runs.join("egress-denials.jsonl");
3048        for f in [&events, &state, &transcript, &denials] {
3049            std::fs::write(f, "engine").unwrap();
3050        }
3051        let control = mission.join("control");
3052        std::fs::create_dir_all(&control).unwrap();
3053        // A runs/ SUBDIRECTORY of session scratch: its jsonl files are not
3054        // transcripts and must NOT be masked.
3055        let contract_home = runs.join("contract-home");
3056        std::fs::create_dir_all(&contract_home).unwrap();
3057        let scratch_jsonl = contract_home.join("notes.jsonl");
3058        std::fs::write(&scratch_jsonl, "session").unwrap();
3059
3060        let args = bubblewrap_args(
3061            &inputs(repo.path(), &mission, scratch.path(), vec![]),
3062            Path::new("/usr/bin/claude"),
3063            &[],
3064        )
3065        .unwrap();
3066        let joined = args.join(" ");
3067
3068        for path in [&events, &state, &mission.join("runs")] {
3069            let path = absolutize(path).display().to_string();
3070            assert!(
3071                joined.contains(&format!("--ro-bind-try {path} {path}")),
3072                "metadata must remain read-only: {args:?}"
3073            );
3074        }
3075        let mission_abs = absolutize(&mission).display().to_string();
3076        assert!(args
3077            .windows(2)
3078            .any(|pair| pair[0] == "--tmpfs" && pair[1] == mission_abs));
3079        assert!(
3080            !args.windows(3).any(|part| {
3081                matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
3082                    && part[1] == absolutize(&control).display().to_string()
3083                    && part[1] == part[2]
3084            }),
3085            "the control inbox must not be rebound into the private mission directory"
3086        );
3087        assert!(
3088            !mission.join("state.json.tmp").exists(),
3089            "argv construction must not create metadata"
3090        );
3091        // Absent metadata files are not masked (bwrap needs the destination
3092        // to exist).
3093        assert!(
3094            !joined.contains("estimate.json"),
3095            "absent metadata files must not be masked: {args:?}"
3096        );
3097        // No rw bind of the mission dir, and runs/-subdir scratch files stay
3098        // unmasked.
3099        let mission_abs = absolutize(&mission);
3100        assert!(
3101            !joined.contains(&format!("--bind {0} {0}", mission_abs.display())),
3102            "mission dir must not be rw-bound: {args:?}"
3103        );
3104        assert!(
3105            !joined.contains(&scratch_jsonl.display().to_string()),
3106            "runs/ subdir scratch files must not be masked: {args:?}"
3107        );
3108    }
3109
3110    #[cfg(unix)]
3111    #[test]
3112    fn bubblewrap_mask_prep_rejects_preexisting_state_tmp_symlink() {
3113        use std::os::unix::fs::symlink;
3114        let repo = tempfile::tempdir().unwrap();
3115        let mission = repo.path().join(".kranz").join("missions").join("m-x");
3116        std::fs::create_dir_all(mission.join("runs")).unwrap();
3117        let target_dir = tempfile::tempdir().unwrap();
3118        let target = target_dir.path().join("outside");
3119        std::fs::write(&target, "unchanged").unwrap();
3120        symlink(&target, mission.join("state.json.tmp")).unwrap();
3121        let scratch = tempfile::tempdir().unwrap();
3122
3123        let error = bubblewrap_args(
3124            &inputs(repo.path(), &mission, scratch.path(), vec![]),
3125            Path::new("/usr/bin/claude"),
3126            &[],
3127        )
3128        .expect_err("a symlink cannot become a bwrap mask mount point");
3129
3130        assert!(error.to_string().contains("not a regular file"), "{error}");
3131        assert_eq!(std::fs::read_to_string(target).unwrap(), "unchanged");
3132        assert!(
3133            std::fs::symlink_metadata(mission.join("state.json.tmp"))
3134                .unwrap()
3135                .file_type()
3136                .is_symlink(),
3137            "mask preparation must not replace or follow the hostile leaf"
3138        );
3139    }
3140
3141    #[test]
3142    fn sandbox_profile_excludes_paths_outside_allowlist() {
3143        let session = tempfile::tempdir().unwrap();
3144        let mission = tempfile::tempdir().unwrap();
3145        let tmp = tempfile::tempdir().unwrap();
3146        let outsider = tempfile::tempdir().unwrap();
3147
3148        let profile = generate_profile(&inputs(session.path(), mission.path(), tmp.path(), vec![]));
3149
3150        let outsider_abs = absolutize(outsider.path());
3151        let forbidden = format!("(subpath \"{}\")", escape_sbpl_literal(&outsider_abs));
3152        assert!(
3153            !profile.contains(&forbidden),
3154            "profile unexpectedly allows write to path outside the allowlist"
3155        );
3156    }
3157
3158    #[test]
3159    fn sandbox_profile_fs_net_restricts_egress_to_loopback() {
3160        let session = tempfile::tempdir().unwrap();
3161        let mission = tempfile::tempdir().unwrap();
3162        let tmp = tempfile::tempdir().unwrap();
3163        let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3164        inputs.enforce = crate::types::SandboxEnforce::FsNet;
3165        inputs.egress = vec!["crates.io:443".into(), "api.anthropic.com:443".into()];
3166
3167        let profile = generate_profile(&inputs);
3168
3169        // Seatbelt rejects hostname egress rules, so the profile cuts outbound
3170        // TCP to loopback only; the per-host allowlist (including the
3171        // configured entries above) is the egress proxy's job, not the SBPL's.
3172        assert!(!profile.contains("(allow network*)"));
3173        assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:*\"))"));
3174        assert!(
3175            !profile.contains("crates.io") && !profile.contains("anthropic.com"),
3176            "no per-host egress rules in the profile:\n{profile}"
3177        );
3178    }
3179
3180    #[test]
3181    fn bubblewrap_args_bind_write_roots_and_unshare_network_for_fs_net() {
3182        let session = tempfile::tempdir().unwrap();
3183        let mission = tempfile::tempdir().unwrap();
3184        let tmp = tempfile::tempdir().unwrap();
3185        let extra = tempfile::tempdir().unwrap();
3186        let mut inputs = inputs(
3187            session.path(),
3188            mission.path(),
3189            tmp.path(),
3190            vec![extra.path().to_path_buf()],
3191        );
3192        inputs.enforce = crate::types::SandboxEnforce::FsNet;
3193
3194        let args =
3195            bubblewrap_args(&inputs, Path::new("/usr/bin/claude"), &["--print".into()]).unwrap();
3196        let joined = args.join(" ");
3197
3198        assert!(args.contains(&"--unshare-net".to_string()));
3199        for path in [
3200            absolutize(session.path()),
3201            absolutize(tmp.path()),
3202            absolutize(extra.path()),
3203        ] {
3204            assert!(
3205                joined.contains(&format!("--bind {0} {0}", path.display())),
3206                "bubblewrap args missing bind for {}: {args:?}",
3207                path.display()
3208            );
3209        }
3210        // The mission dir is bound read-only via the whole-fs ro-bind only —
3211        // never re-bound writable.
3212        let mission_abs = absolutize(mission.path());
3213        assert!(
3214            !joined.contains(&format!("--bind {0} {0}", mission_abs.display())),
3215            "bubblewrap args must not rw-bind the mission dir: {args:?}"
3216        );
3217        assert!(joined.contains("--ro-bind / /"));
3218        assert!(joined.ends_with("/usr/bin/claude --print"));
3219    }
3220
3221    /// H8 (2026-09-01 adversarial audit): the argv unshared ONLY the network
3222    /// namespace, and only under `fs+net`. Host `/proc` was therefore the
3223    /// engine's own `/proc`, so a contained agent could read
3224    /// `/proc/<engine>/environ` (the very set `agent_env` exists to withhold)
3225    /// on a `ptrace_scope = 0` host, keep the controlling terminal, and
3226    /// signal the engine. Every namespace flag is unconditional; only
3227    /// `--unshare-net` stays tier-gated, because it is an egress decision.
3228    #[test]
3229    fn bubblewrap_args_unshare_every_namespace_on_both_tiers() {
3230        let session = tempfile::tempdir().unwrap();
3231        let mission = tempfile::tempdir().unwrap();
3232        let tmp = tempfile::tempdir().unwrap();
3233
3234        for enforce in [
3235            crate::types::SandboxEnforce::Fs,
3236            crate::types::SandboxEnforce::FsNet,
3237        ] {
3238            let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3239            inputs.enforce = enforce;
3240            let args = bubblewrap_args(&inputs, Path::new("/usr/bin/claude"), &[]).unwrap();
3241
3242            for flag in [
3243                "--unshare-pid",
3244                "--unshare-ipc",
3245                "--unshare-uts",
3246                "--unshare-cgroup-try",
3247                "--new-session",
3248                "--die-with-parent",
3249            ] {
3250                assert!(
3251                    args.contains(&flag.to_string()),
3252                    "{enforce:?} argv missing {flag}: {args:?}"
3253                );
3254            }
3255            // M-8 (follow-up review): the non-try form makes bwrap EXIT
3256            // non-zero where cgroup namespaces are unavailable (kernels
3257            // before 4.6, nested containers, hardened kernels), and every
3258            // resolver here fails closed — so the whole session dies rather
3259            // than degrading by one namespace.
3260            assert!(
3261                !args.contains(&"--unshare-cgroup".to_string()),
3262                "the non-try cgroup unshare must never be emitted: {args:?}"
3263            );
3264            assert_eq!(
3265                args.contains(&"--unshare-net".to_string()),
3266                enforce == crate::types::SandboxEnforce::FsNet,
3267                "--unshare-net is the one tier-gated namespace: {args:?}"
3268            );
3269        }
3270    }
3271
3272    /// The canonical `<repo>/.kranz/missions/<id>` fixture the authority
3273    /// write denies need: a repo root with the mission's own dir, a SIBLING
3274    /// mission, the repo-level engine stores, and a `.git`.
3275    fn authority_write_fixture() -> (tempfile::TempDir, PathBuf) {
3276        let repo = tempfile::tempdir().unwrap();
3277        let kranz = repo.path().join(".kranz");
3278        let mission = kranz.join("missions").join("m-x");
3279        std::fs::create_dir_all(mission.join("runs").join("scratch")).unwrap();
3280        std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
3281        for name in ["queue", "tickets", "lessons", "hook-status"] {
3282            std::fs::create_dir_all(kranz.join(name)).unwrap();
3283        }
3284        for name in ["config.json", "serve.token", "serve.read.token"] {
3285            std::fs::write(kranz.join(name), "secret").unwrap();
3286        }
3287        std::fs::create_dir_all(repo.path().join(".git").join("hooks")).unwrap();
3288        std::fs::create_dir_all(repo.path().join(".git").join("info")).unwrap();
3289        std::fs::write(repo.path().join(".git").join("config"), "[core]\n").unwrap();
3290        (repo, mission)
3291    }
3292
3293    /// H2 + H11 (2026-09-01 adversarial audit): `.kranz/config.json` was
3294    /// read-denied but WRITE-allowed, and every metadata deny was derived
3295    /// from the session's OWN mission dir. Under checkout mode `session_cwd`
3296    /// is the repo root, so a contained worker could overwrite the trust
3297    /// root it could not read, and forge approvals into a sibling mission.
3298    #[test]
3299    fn sandbox_profile_denies_authority_material_writes() {
3300        let (repo, mission) = authority_write_fixture();
3301        let tmp = tempfile::tempdir().unwrap();
3302        let kranz = repo.path().join(".kranz");
3303
3304        // Checkout mode: session_cwd IS the repo root, the hostile shape.
3305        let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
3306
3307        for name in ["config.json", "serve.token", "serve.read.token"] {
3308            let expected = format!(
3309                "(literal \"{}\")",
3310                escape_sbpl_literal(&absolutize(&kranz.join(name)))
3311            );
3312            assert!(
3313                profile.contains(&expected),
3314                "profile missing write deny for .kranz/{name}:\n{profile}"
3315            );
3316        }
3317        for name in ["queue", "tickets", "lessons", "hook-status"] {
3318            let expected = format!(
3319                "(subpath \"{}\")",
3320                escape_sbpl_literal(&absolutize(&kranz.join(name)))
3321            );
3322            assert!(
3323                profile.contains(&expected),
3324                "profile missing write deny for .kranz/{name}/:\n{profile}"
3325            );
3326        }
3327        // The SIBLING mission is denied; the session's OWN mission dir is
3328        // not denied wholesale (mission_write_denies keeps the narrow set,
3329        // and runs/<scratch> must stay writable).
3330        let other = absolutize(&kranz.join("missions").join("m-other"));
3331        assert!(
3332            profile.contains(&format!("(subpath \"{}\")", escape_sbpl_literal(&other))),
3333            "profile missing write deny for the sibling mission dir:\n{profile}"
3334        );
3335        let own = absolutize(&mission);
3336        assert!(
3337            !profile.contains(&format!("(subpath \"{}\")\n", escape_sbpl_literal(&own))),
3338            "the session's own mission dir must not be denied wholesale:\n{profile}"
3339        );
3340        // The sealing regexes: no NEW store under `.kranz`, no NEW sibling
3341        // mission dir, after the profile was built.
3342        for root in [absolutize(&kranz), absolutize(&kranz.join("missions"))] {
3343            let expected = format!("(regex #\"^{}/[^/]*$\")", escape_sbpl_regex(&root));
3344            assert!(
3345                profile.contains(&expected),
3346                "profile missing the sealing regex for {}:\n{profile}",
3347                root.display()
3348            );
3349        }
3350    }
3351
3352    /// The global kranz key dir (`~/.kranz/keys`) is denied for BOTH reads
3353    /// and writes on every tier (2026-09-01 adversarial audit, C1/H6): the
3354    /// consent substrate's MAC key has to live outside a sandboxed session's
3355    /// readable set, or the MAC proves nothing against the agent it excludes;
3356    /// and a session that could replace the key could re-sign anything.
3357    #[test]
3358    fn global_key_dir_is_read_and_write_denied() {
3359        // The global dir is resolved once per process (`paths::global_kranz_dir`),
3360        // so the test reads the resolved value instead of rebinding HOME: the
3361        // property under test is that the deny follows the SAME resolver the
3362        // key writer and the seal recorder use.
3363        let global = crate::paths::global_kranz_dir().expect("a global kranz dir resolves");
3364        let (repo, mission) = authority_write_fixture();
3365        let tmp = tempfile::tempdir().unwrap();
3366        let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3367
3368        for store in ["keys", "seals"] {
3369            let dir = global.join(store);
3370            assert!(
3371                authority_read_deny_dirs(&inputs).contains(&dir),
3372                "the global {store} dir must be read-denied"
3373            );
3374            assert!(
3375                authority_write_denies(&inputs).dirs.contains(&dir),
3376                "the global {store} dir must be write-denied"
3377            );
3378            let profile = generate_profile(&inputs);
3379            let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&dir));
3380            assert!(
3381                profile.matches(&expected).count() >= 2,
3382                "the {store} dir belongs in BOTH the read-deny and the write-deny block:\n{profile}"
3383            );
3384        }
3385    }
3386
3387    /// A mission dir that is NOT in the canonical
3388    /// `<repo>/.kranz/missions/<id>` layout must yield no sweep at all — the
3389    /// derivation walks parents, and a bare temp-dir mission would otherwise
3390    /// seal the system temp root (or `/`) against every write.
3391    #[test]
3392    fn authority_write_denies_refuse_a_noncanonical_mission_layout() {
3393        let session = tempfile::tempdir().unwrap();
3394        let mission = tempfile::tempdir().unwrap();
3395        let tmp = tempfile::tempdir().unwrap();
3396        let inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
3397
3398        assert!(
3399            sealed_kranz_dir_roots(&inputs).is_empty(),
3400            "a non-canonical mission dir must seal nothing"
3401        );
3402        let denies = authority_write_denies(&inputs);
3403        let temp_root = absolutize(&std::env::temp_dir());
3404        assert!(
3405            !denies.dirs.iter().any(|d| d == &temp_root),
3406            "the sweep must never reach the system temp root: {:?}",
3407            denies.dirs
3408        );
3409    }
3410
3411    /// H3 support (2026-09-01 adversarial audit): the engine checkpoints with
3412    /// an UNHARDENED git handle in the tree the worker controls, so a planted
3413    /// `.git/hooks/pre-commit` or a `core.sshCommand` in `.git/config`
3414    /// executes on the host with the engine's full environment. The deny is
3415    /// narrow because the worker's own role is to commit.
3416    #[test]
3417    fn sandbox_profile_denies_git_config_and_hook_writes_but_not_the_index() {
3418        let (repo, mission) = authority_write_fixture();
3419        let tmp = tempfile::tempdir().unwrap();
3420
3421        let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
3422
3423        let git = absolutize(&repo.path().join(".git"));
3424        for dir in ["hooks", "info"] {
3425            let expected = format!("(subpath \"{}\")", escape_sbpl_literal(&git.join(dir)));
3426            assert!(
3427                profile.contains(&expected),
3428                "profile missing write deny for .git/{dir}/:\n{profile}"
3429            );
3430        }
3431        for file in ["config", "config.worktree"] {
3432            let expected = format!("(literal \"{}\")", escape_sbpl_literal(&git.join(file)));
3433            assert!(
3434                profile.contains(&expected),
3435                "profile missing write deny for .git/{file}:\n{profile}"
3436            );
3437        }
3438        // The gitlink FILE form (worktree mode) is denied as a literal, which
3439        // in checkout mode denies a replace of the `.git` directory node.
3440        assert!(
3441            profile.contains(&format!("(literal \"{}\")", escape_sbpl_literal(&git))),
3442            "profile missing write deny for the .git node itself:\n{profile}"
3443        );
3444        // The commit path stays open: nothing denies the index or objects.
3445        for open in ["index", "objects", "refs"] {
3446            let denied = format!("(subpath \"{}\")", escape_sbpl_literal(&git.join(open)));
3447            assert!(
3448                !profile.contains(&denied),
3449                ".git/{open} must stay writable — the worker commits:\n{profile}"
3450            );
3451        }
3452    }
3453
3454    /// M-9 (follow-up review): a submodule keeps its own `config` and
3455    /// `hooks/` under `.git/modules/<name>/`, which is the SAME host-execution
3456    /// surface `.git/config` and `.git/hooks/` are — and in checkout mode it
3457    /// sits inside the rw session bind. The subtree deny covers every
3458    /// submodule, present and future; git never needs to write it from
3459    /// inside the sandbox.
3460    #[test]
3461    fn git_metadata_write_denies_cover_the_submodule_config_and_hook_surface() {
3462        let (repo, mission) = authority_write_fixture();
3463        let tmp = tempfile::tempdir().unwrap();
3464        let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3465
3466        let modules = absolutize(&repo.path().join(".git").join("modules"));
3467        assert!(
3468            git_metadata_write_denies(&inputs).dirs.contains(&modules),
3469            "the .git/modules subtree must be write-denied: {:?}",
3470            git_metadata_write_denies(&inputs).dirs
3471        );
3472        let profile = generate_profile(&inputs);
3473        assert!(
3474            profile.contains(&format!("(subpath \"{}\")", escape_sbpl_literal(&modules))),
3475            "profile missing write deny for .git/modules/:\n{profile}"
3476        );
3477    }
3478
3479    /// The bwrap analogue of the two blocks above: each denied path is
3480    /// ro-bound over itself (readable, unwritable), and the `.git` DIRECTORY
3481    /// node is deliberately excluded — binding it whole would close the index
3482    /// the worker's own `git commit` writes.
3483    /// Later binds win in bwrap. A read-denied file is closed by its
3484    /// /dev/null mask; a self ro-bind of the same path emitted afterwards
3485    /// would put the real content back. Every masked path must therefore be
3486    /// absent from the self-bind set (Linux CI receipt, 2026-09-03).
3487    #[test]
3488    fn bubblewrap_args_never_self_bind_a_masked_authority_path() {
3489        let (repo, mission) = authority_write_fixture();
3490        let tmp = tempfile::tempdir().unwrap();
3491        let inputs = inputs(repo.path(), &mission, tmp.path(), vec![]);
3492        let args = bubblewrap_args(&inputs, Path::new("/bin/true"), &[]).unwrap();
3493        let masked: Vec<String> = authority_read_deny_paths(&inputs)
3494            .iter()
3495            .map(|path| absolutize(path).display().to_string())
3496            .collect();
3497        let kranz = absolutize(&repo.path().join(".kranz"))
3498            .display()
3499            .to_string();
3500        assert!(args
3501            .windows(2)
3502            .any(|pair| pair[0] == "--tmpfs" && pair[1] == kranz));
3503        let mut i = 0;
3504        while i + 2 < args.len() {
3505            if matches!(args[i].as_str(), "--ro-bind" | "--ro-bind-try")
3506                && args[i + 1] == args[i + 2]
3507            {
3508                assert!(
3509                    !masked.contains(&args[i + 2]),
3510                    "{} is masked and must not be re-bound over itself",
3511                    args[i + 2]
3512                );
3513            }
3514            i += 1;
3515        }
3516    }
3517
3518    #[test]
3519    fn bubblewrap_args_ro_bind_authority_and_git_write_denies() {
3520        let (repo, mission) = authority_write_fixture();
3521        let tmp = tempfile::tempdir().unwrap();
3522        let kranz = repo.path().join(".kranz");
3523
3524        let args = bubblewrap_args(
3525            &inputs(repo.path(), &mission, tmp.path(), vec![]),
3526            Path::new("/usr/bin/claude"),
3527            &[],
3528        )
3529        .unwrap();
3530        let joined = args.join(" ");
3531
3532        for path in [
3533            kranz.join("queue"),
3534            kranz.join("tickets"),
3535            kranz.join("lessons"),
3536            kranz.join("missions").join("m-other"),
3537            repo.path().join(".git").join("hooks"),
3538            repo.path().join(".git").join("info"),
3539            repo.path().join(".git").join("config"),
3540        ] {
3541            let expected = format!("--ro-bind {0} {0}", lexical_absolute(&path).display());
3542            assert!(
3543                joined.contains(&expected),
3544                "bwrap argv missing the write-closing ro-bind for {}: {args:?}",
3545                path.display()
3546            );
3547        }
3548        let git = lexical_absolute(&repo.path().join(".git"));
3549        assert!(
3550            !joined.contains(&format!("--ro-bind {0} {0}", git.display())),
3551            "the .git DIRECTORY must never be ro-bound whole — the worker commits: {args:?}"
3552        );
3553    }
3554
3555    /// H7 (2026-09-01 adversarial audit): the operator's own terminal must
3556    /// be denied read, write AND ioctl — the last is what closes TIOCSTI —
3557    /// even though the gate extras still ALLOW the pty device class the
3558    /// harness's `openpty` needs, and even though `/dev/ttys003` is matched
3559    /// by that class. Rendered from an explicit path list so the assertion
3560    /// holds on a test runner with no controlling terminal of its own.
3561    #[test]
3562    fn tty_deny_block_denies_ioctl_on_the_named_terminal_and_nothing_when_absent() {
3563        let block = tty_deny_block(&[
3564            PathBuf::from("/dev/ttys003"),
3565            PathBuf::from("/dev/ttys003"),
3566            PathBuf::from("/dev/ttys001"),
3567        ]);
3568        assert!(block.starts_with("(deny file-read* file-write* file-ioctl\n"));
3569        assert!(block.contains("(literal \"/dev/ttys003\")"), "{block}");
3570        assert!(block.contains("(literal \"/dev/ttys001\")"), "{block}");
3571        assert_eq!(
3572            block.matches("/dev/ttys003").count(),
3573            1,
3574            "duplicate fds must collapse to one literal:\n{block}"
3575        );
3576        assert!(
3577            tty_deny_block(&[]).is_empty(),
3578            "no controlling terminal means no deny block"
3579        );
3580    }
3581
3582    #[test]
3583    fn sandbox_profile_write_profile_file_roundtrip() {
3584        let dir = tempfile::tempdir().unwrap();
3585        let profile = "(version 1)\n(deny default)\n";
3586        let path = write_profile_file(dir.path(), profile).unwrap();
3587        assert_eq!(std::fs::read_to_string(&path).unwrap(), profile);
3588        assert!(path.starts_with(dir.path()));
3589    }
3590
3591    #[test]
3592    fn sandbox_platform_support_matrix() {
3593        use crate::types::SandboxEnforce;
3594
3595        assert_eq!(
3596            platform_support(SandboxEnforce::Off, "macos"),
3597            SandboxDecision::Off
3598        );
3599        assert_eq!(
3600            platform_support(SandboxEnforce::Fs, "macos"),
3601            SandboxDecision::Enforce(SandboxBackend::Seatbelt)
3602        );
3603        assert_eq!(
3604            platform_support(SandboxEnforce::Fs, "linux"),
3605            SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
3606        );
3607        assert_eq!(
3608            platform_support(SandboxEnforce::FsNet, "macos"),
3609            SandboxDecision::Enforce(SandboxBackend::Seatbelt)
3610        );
3611        assert_eq!(
3612            platform_support(SandboxEnforce::FsNet, "linux"),
3613            SandboxDecision::Enforce(SandboxBackend::Bubblewrap)
3614        );
3615        assert_eq!(
3616            platform_support(SandboxEnforce::Fs, "windows"),
3617            SandboxDecision::Enforce(SandboxBackend::AppContainer)
3618        );
3619        assert_eq!(
3620            platform_support(SandboxEnforce::FsNet, "windows"),
3621            SandboxDecision::Enforce(SandboxBackend::AppContainer)
3622        );
3623        assert_eq!(
3624            platform_support(SandboxEnforce::Off, "linux"),
3625            SandboxDecision::Off
3626        );
3627    }
3628
3629    #[test]
3630    fn sandbox_resolve_off_yields_none() {
3631        let cfg = crate::types::SandboxConfig {
3632            enforce: crate::types::SandboxEnforce::Off,
3633            provider: crate::types::SandboxProvider::Process,
3634            image: None,
3635            extra_write: vec![],
3636            egress: vec![],
3637        };
3638        let session = tempfile::tempdir().unwrap();
3639        let mission = tempfile::tempdir().unwrap();
3640
3641        let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
3642        assert!(resolved.is_none());
3643        assert!(warn.is_none());
3644    }
3645
3646    #[test]
3647    fn sandbox_resolve_linux_requires_bwrap() {
3648        let cfg = crate::types::SandboxConfig {
3649            enforce: crate::types::SandboxEnforce::Fs,
3650            provider: crate::types::SandboxProvider::Process,
3651            image: None,
3652            extra_write: vec![],
3653            egress: vec![],
3654        };
3655        let session = tempfile::tempdir().unwrap();
3656        let mission = tempfile::tempdir().unwrap();
3657
3658        let (resolved, warn) = resolve_for_session_target(
3659            &cfg,
3660            session.path(),
3661            mission.path(),
3662            "linux",
3663            false,
3664            None,
3665            None,
3666        );
3667        assert!(resolved.is_none());
3668        assert!(
3669            warn.unwrap().contains("bwrap"),
3670            "missing-bwrap warning should name bwrap"
3671        );
3672
3673        let (resolved, warn) = resolve_for_session_target(
3674            &cfg,
3675            session.path(),
3676            mission.path(),
3677            "linux",
3678            true,
3679            None,
3680            None,
3681        );
3682        assert!(warn.is_none());
3683        assert_eq!(
3684            resolved.expect("bwrap present").backend,
3685            SandboxBackend::Bubblewrap
3686        );
3687    }
3688
3689    fn container_cfg(
3690        enforce: crate::types::SandboxEnforce,
3691        egress: Vec<String>,
3692    ) -> crate::types::SandboxConfig {
3693        crate::types::SandboxConfig {
3694            enforce,
3695            provider: crate::types::SandboxProvider::Container,
3696            image: None,
3697            extra_write: vec![],
3698            egress,
3699        }
3700    }
3701
3702    #[test]
3703    fn container_provider_off_stays_unsandboxed() {
3704        let cfg = container_cfg(crate::types::SandboxEnforce::Off, vec![]);
3705        let session = tempfile::tempdir().unwrap();
3706        let mission = tempfile::tempdir().unwrap();
3707
3708        let (resolved, warn) = resolve_for_session_target(
3709            &cfg,
3710            session.path(),
3711            mission.path(),
3712            "macos",
3713            false,
3714            None,
3715            None,
3716        );
3717        assert!(resolved.is_none());
3718        assert!(warn.is_none());
3719    }
3720
3721    #[test]
3722    fn container_provider_on_macos_resolves_only_against_a_mount_proof() {
3723        use crate::sandbox_container::{ContainerRuntime, MountProof};
3724        let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3725        let session = tempfile::tempdir().unwrap();
3726        let mission = tempfile::tempdir().unwrap();
3727        let resolve = |proof: Option<MountProof>| {
3728            resolve_for_session_target(
3729                &cfg,
3730                session.path(),
3731                mission.path(),
3732                "macos",
3733                false,
3734                Some(ContainerRuntime::Docker),
3735                proof,
3736            )
3737        };
3738
3739        // A host that proved the round trip is supported, receipt or no receipt.
3740        let (resolved, warn) = resolve(Some(MountProof::Proven));
3741        assert!(resolved.is_some(), "a proven mount must resolve: {warn:?}");
3742
3743        // A host whose mount shares nothing is refused, and the operator is
3744        // told which path failed rather than that the platform is unsupported.
3745        let (resolved, warn) = resolve(Some(MountProof::Failed(
3746            "docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
3747        )));
3748        assert!(resolved.is_none());
3749        let warn = warn.expect("a failed proof must refuse loudly");
3750        assert!(warn.contains("/var/folders/x"), "{warn}");
3751        assert!(warn.contains("shared nothing"), "{warn}");
3752
3753        // No proof is not the same as a passing proof.
3754        let (resolved, warn) = resolve(None);
3755        assert!(resolved.is_none());
3756        let warn = warn.expect("an unproven host must refuse");
3757        assert!(warn.contains("requires a bind-mount proof"), "{warn}");
3758    }
3759
3760    #[test]
3761    fn container_provider_on_windows_refuses_even_a_proven_mount() {
3762        use crate::sandbox_container::{ContainerRuntime, MountProof};
3763        let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3764        let session = tempfile::tempdir().unwrap();
3765        let mission = tempfile::tempdir().unwrap();
3766
3767        // Windows fails the POSIX guest-path and /dev/null authority-mask
3768        // contract, which a mount proof says nothing about.
3769        let (resolved, warn) = resolve_for_session_target(
3770            &cfg,
3771            session.path(),
3772            mission.path(),
3773            "windows",
3774            false,
3775            Some(ContainerRuntime::Docker),
3776            Some(MountProof::Proven),
3777        );
3778        assert!(resolved.is_none());
3779        let warn = warn.expect("windows must refuse");
3780        assert!(
3781            warn.contains("not supported on target_os=windows"),
3782            "{warn}"
3783        );
3784        assert!(warn.contains("POSIX guest paths"), "{warn}");
3785    }
3786
3787    #[test]
3788    fn container_provider_without_runtime_fails_closed() {
3789        let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3790        let session = tempfile::tempdir().unwrap();
3791        let mission = tempfile::tempdir().unwrap();
3792
3793        let (resolved, warn) = resolve_for_session_target(
3794            &cfg,
3795            session.path(),
3796            mission.path(),
3797            "linux",
3798            false,
3799            None,
3800            None,
3801        );
3802        assert!(resolved.is_none());
3803        let warn = warn.expect("missing runtime must produce a warning");
3804        assert!(warn.contains("provider:container"), "{warn}");
3805        assert!(warn.contains("docker/podman/nerdctl/container"), "{warn}");
3806        assert!(warn.contains("refusing to run unsandboxed"), "{warn}");
3807    }
3808
3809    #[test]
3810    fn macos_enforced_container_provider_fails_closed_to_native_process_guidance() {
3811        let cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3812        let session = tempfile::tempdir().unwrap();
3813        let mission = tempfile::tempdir().unwrap();
3814
3815        let (resolved, warning) = resolve_for_session_target(
3816            &cfg,
3817            session.path(),
3818            mission.path(),
3819            "macos",
3820            false,
3821            Some(crate::sandbox_container::ContainerRuntime::Docker),
3822            None,
3823        );
3824        assert!(resolved.is_none());
3825        let warning = warning.expect("an unproved macOS container must refuse");
3826        // The refusal is now about THIS host's evidence, not about the
3827        // platform: an unproved macOS host is refused, and a proved one
3828        // resolves (container_provider_on_macos_resolves_only_against_a_mount_proof).
3829        assert!(warning.contains("requires a bind-mount proof"), "{warning}");
3830        assert!(
3831            warning.contains("sandbox.provider=\"process\""),
3832            "{warning}"
3833        );
3834        assert!(warning.contains("native host containment"), "{warning}");
3835    }
3836
3837    /// M7 Windows parity, phase 4: the process provider resolves the stable
3838    /// AppContainer backend. Merely finding `docker.exe` still does not prove
3839    /// the Windows container mount contract, so that provider stays refused;
3840    /// `off` remains the operator's explicit unsandboxed posture.
3841    #[test]
3842    fn windows_enforced_session_process_resolves_appcontainer_while_container_fails_closed() {
3843        let session = tempfile::tempdir().unwrap();
3844        let mission = tempfile::tempdir().unwrap();
3845
3846        for enforce in [
3847            crate::types::SandboxEnforce::Fs,
3848            crate::types::SandboxEnforce::FsNet,
3849        ] {
3850            let process = crate::types::SandboxConfig {
3851                enforce,
3852                provider: crate::types::SandboxProvider::Process,
3853                image: None,
3854                extra_write: vec![],
3855                egress: vec![],
3856            };
3857            let (resolved, warning) = resolve_for_session_target(
3858                &process,
3859                session.path(),
3860                mission.path(),
3861                "windows",
3862                false,
3863                Some(crate::sandbox_container::ContainerRuntime::Docker),
3864                None,
3865            );
3866            assert!(warning.is_none(), "{warning:?}");
3867            let resolved = resolved.expect("Windows process enforcement resolves");
3868            assert_eq!(resolved.backend, SandboxBackend::AppContainer);
3869            assert_eq!(resolved.inputs.enforce, enforce);
3870            assert_eq!(resolved.inputs.session_cwd, session.path());
3871            assert_eq!(resolved.inputs.mission_dir, mission.path());
3872
3873            let container = container_cfg(enforce, vec![]);
3874            let (resolved, warning) = resolve_for_session_target(
3875                &container,
3876                session.path(),
3877                mission.path(),
3878                "windows",
3879                false,
3880                Some(crate::sandbox_container::ContainerRuntime::Docker),
3881                None,
3882            );
3883            assert!(resolved.is_none());
3884            let warning = warning.expect("an unproved Windows container must refuse");
3885            // Windows is refused on its own contract gap, not for want of a
3886            // mount proof: guest paths and /dev/null masks are what fail
3887            // there, so no probe result could change this answer.
3888            assert!(
3889                warning.contains("not supported on target_os=windows"),
3890                "{warning}"
3891            );
3892            assert!(
3893                warning.contains("unverified container mount contract"),
3894                "{warning}"
3895            );
3896        }
3897
3898        let off = container_cfg(crate::types::SandboxEnforce::Off, vec![]);
3899        let (resolved, warning) = resolve_for_session_target(
3900            &off,
3901            session.path(),
3902            mission.path(),
3903            "windows",
3904            false,
3905            Some(crate::sandbox_container::ContainerRuntime::Docker),
3906            None,
3907        );
3908        assert!(resolved.is_none());
3909        assert!(warning.is_none());
3910    }
3911
3912    #[test]
3913    fn container_provider_fs_net_with_egress_list_resolves_for_the_proxy() {
3914        let cfg = container_cfg(
3915            crate::types::SandboxEnforce::FsNet,
3916            vec!["crates.io:443".to_string()],
3917        );
3918        let session = tempfile::tempdir().unwrap();
3919        let mission = tempfile::tempdir().unwrap();
3920
3921        // Docker resolves the posture; the runner provisions the unique
3922        // internal network + authenticated relay before session spawn.
3923        let (resolved, warn) = resolve_for_session_target(
3924            &cfg,
3925            session.path(),
3926            mission.path(),
3927            "linux",
3928            false,
3929            Some(crate::sandbox_container::ContainerRuntime::Docker),
3930            None,
3931        );
3932        assert!(warn.is_none(), "{warn:?}");
3933        let resolved = resolved.expect("container fs+net with egress must resolve");
3934        assert_eq!(resolved.backend, SandboxBackend::Container);
3935        assert_eq!(resolved.inputs.egress, vec!["crates.io:443".to_string()]);
3936    }
3937
3938    #[test]
3939    fn container_provider_fs_net_with_egress_refuses_non_docker_runtime() {
3940        let cfg = container_cfg(
3941            crate::types::SandboxEnforce::FsNet,
3942            vec!["crates.io:443".to_string()],
3943        );
3944        let session = tempfile::tempdir().unwrap();
3945        let mission = tempfile::tempdir().unwrap();
3946        let (resolved, warning) = resolve_for_session_target(
3947            &cfg,
3948            session.path(),
3949            mission.path(),
3950            "linux",
3951            false,
3952            Some(crate::sandbox_container::ContainerRuntime::Podman),
3953            None,
3954        );
3955        assert!(resolved.is_none());
3956        let warning = warning.expect("unproved runtime must fail closed");
3957        assert!(warning.contains("requires Docker"), "{warning}");
3958        assert!(warning.contains("podman"), "{warning}");
3959    }
3960
3961    #[test]
3962    fn container_provider_resolves_runtime_and_image() {
3963        let session = tempfile::tempdir().unwrap();
3964        let mission = tempfile::tempdir().unwrap();
3965
3966        // Default image when config names none.
3967        let cfg = container_cfg(crate::types::SandboxEnforce::FsNet, vec![]);
3968        let (resolved, warn) = resolve_for_session_target(
3969            &cfg,
3970            session.path(),
3971            mission.path(),
3972            "linux",
3973            false,
3974            Some(crate::sandbox_container::ContainerRuntime::Podman),
3975            None,
3976        );
3977        assert!(warn.is_none());
3978        let resolved = resolved.expect("runtime present and policy supportable");
3979        assert_eq!(resolved.backend, SandboxBackend::Container);
3980        let container = resolved.container.expect("container spec must be set");
3981        assert_eq!(
3982            container.runtime,
3983            crate::sandbox_container::ContainerRuntime::Podman
3984        );
3985        assert_eq!(container.image, crate::sandbox_container::DEFAULT_IMAGE);
3986
3987        // Configured image overrides the default.
3988        let mut cfg = container_cfg(crate::types::SandboxEnforce::Fs, vec![]);
3989        cfg.image = Some("ghcr.io/example/kranz-worker:1".to_string());
3990        let (resolved, warn) = resolve_for_session_target(
3991            &cfg,
3992            session.path(),
3993            mission.path(),
3994            "linux",
3995            false,
3996            Some(crate::sandbox_container::ContainerRuntime::Docker),
3997            None,
3998        );
3999        assert!(warn.is_none());
4000        assert_eq!(
4001            resolved
4002                .expect("runtime present")
4003                .container
4004                .expect("container spec")
4005                .image,
4006            "ghcr.io/example/kranz-worker:1"
4007        );
4008    }
4009
4010    #[cfg(target_os = "macos")]
4011    #[test]
4012    fn sandbox_resolve_fs_on_macos_yields_resolved_sandbox() {
4013        let cfg = crate::types::SandboxConfig {
4014            enforce: crate::types::SandboxEnforce::Fs,
4015            provider: crate::types::SandboxProvider::Process,
4016            image: None,
4017            extra_write: vec![],
4018            egress: vec![],
4019        };
4020        let session = tempfile::tempdir().unwrap();
4021        let mission = tempfile::tempdir().unwrap();
4022
4023        let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
4024        assert!(warn.is_none());
4025        let resolved = resolved.expect("expected an enforced sandbox on macos");
4026        assert_eq!(resolved.backend, SandboxBackend::Seatbelt);
4027        assert_eq!(resolved.inputs.session_cwd, session.path());
4028        assert_eq!(resolved.inputs.mission_dir, mission.path());
4029        assert!(!resolved.inputs.tmpdir.as_os_str().is_empty());
4030    }
4031
4032    #[cfg(target_os = "macos")]
4033    #[test]
4034    fn sandbox_resolve_prewarms_apple_git_cache_before_profile_use() {
4035        use std::process::Command;
4036
4037        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4038        if !sandbox_exec_can_apply() {
4039            return;
4040        }
4041
4042        let repo = tempfile::tempdir().unwrap();
4043        let init = Command::new("/usr/bin/git")
4044            .args(["init", "--quiet"])
4045            .current_dir(repo.path())
4046            .env("GIT_CONFIG_NOSYSTEM", "1")
4047            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4048            .status()
4049            .expect("initialize disposable repository");
4050        assert!(init.success());
4051        let mission = tempfile::tempdir().unwrap();
4052        let cfg = crate::types::SandboxConfig {
4053            enforce: crate::types::SandboxEnforce::Fs,
4054            provider: crate::types::SandboxProvider::Process,
4055            image: None,
4056            extra_write: vec![],
4057            egress: vec![],
4058        };
4059
4060        // Resolution performs the bounded host-side prewarm before the
4061        // generated profile can deny the shared xcrun cache refresh.
4062        let (resolved, warn) = resolve_for_session(&cfg, repo.path(), mission.path());
4063        assert!(warn.is_none());
4064        let resolved = resolved.expect("Seatbelt resolves on macOS");
4065        let profile_dir = tempfile::tempdir().unwrap();
4066        let profile_path =
4067            write_profile_file(profile_dir.path(), &generate_profile(&resolved.inputs)).unwrap();
4068        let output = Command::new("sandbox-exec")
4069            .arg("-f")
4070            .arg(profile_path)
4071            .arg("/usr/bin/git")
4072            .args(["status", "--short"])
4073            .current_dir(repo.path())
4074            .env("GIT_CONFIG_NOSYSTEM", "1")
4075            .env("GIT_CONFIG_GLOBAL", "/dev/null")
4076            .output()
4077            .expect("run Apple Git under the resolved profile");
4078        let stderr = String::from_utf8_lossy(&output.stderr);
4079        assert!(output.status.success(), "Apple Git must run: {stderr}");
4080        assert!(
4081            !stderr.contains("xcrun_db"),
4082            "the host-side prewarm must prevent an in-sandbox cache refresh: {stderr}"
4083        );
4084    }
4085
4086    #[cfg(target_os = "macos")]
4087    #[test]
4088    fn sandbox_resolve_expands_tilde_extra_write_via_home() {
4089        let cfg = crate::types::SandboxConfig {
4090            enforce: crate::types::SandboxEnforce::Fs,
4091            provider: crate::types::SandboxProvider::Process,
4092            image: None,
4093            extra_write: vec!["~/.cargo".to_string()],
4094            egress: vec![],
4095        };
4096        let session = tempfile::tempdir().unwrap();
4097        let mission = tempfile::tempdir().unwrap();
4098        let home = std::env::var("HOME").expect("HOME must be set to run this test");
4099
4100        let (resolved, _warn) = resolve_for_session(&cfg, session.path(), mission.path());
4101        let resolved = resolved.expect("expected an enforced sandbox on macos");
4102        assert_eq!(
4103            resolved.inputs.extra_write,
4104            vec![PathBuf::from(home).join(".cargo")]
4105        );
4106    }
4107
4108    #[cfg(target_os = "macos")]
4109    #[test]
4110    fn sandbox_resolve_fs_net_on_macos_yields_seatbelt_with_loopback_profile() {
4111        let cfg = crate::types::SandboxConfig {
4112            enforce: crate::types::SandboxEnforce::FsNet,
4113            provider: crate::types::SandboxProvider::Process,
4114            image: None,
4115            extra_write: vec![],
4116            egress: vec![],
4117        };
4118        let session = tempfile::tempdir().unwrap();
4119        let mission = tempfile::tempdir().unwrap();
4120
4121        let (resolved, warn) = resolve_for_session(&cfg, session.path(), mission.path());
4122
4123        assert!(warn.is_none(), "fs+net on macOS resolves: {warn:?}");
4124        let resolved = resolved.expect("fs+net on macOS resolves to Seatbelt");
4125        assert_eq!(resolved.backend, SandboxBackend::Seatbelt);
4126        let profile = generate_profile(&resolved.inputs);
4127        assert!(
4128            profile.contains("(allow network-outbound (remote tcp \"localhost:*\"))"),
4129            "fs+net profile must restrict egress to loopback so only the egress proxy is reachable:\n{profile}"
4130        );
4131    }
4132
4133    #[cfg(target_os = "macos")]
4134    #[test]
4135    fn sandbox_enforcement_macos_allows_inside_denies_outside() {
4136        use std::process::Command;
4137
4138        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4139
4140        if !sandbox_exec_can_apply() {
4141            return;
4142        }
4143
4144        let session = tempfile::tempdir().unwrap();
4145        let mission = tempfile::tempdir().unwrap();
4146        let tmp = tempfile::tempdir().unwrap();
4147        let outside = tempfile::tempdir().unwrap();
4148
4149        let profile = generate_profile(&inputs(session.path(), mission.path(), tmp.path(), vec![]));
4150        let profile_dir = tempfile::tempdir().unwrap();
4151        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4152
4153        let inside_file = session.path().join("inside.txt");
4154        let inside_status = Command::new("sandbox-exec")
4155            .arg("-f")
4156            .arg(&profile_path)
4157            .arg("/bin/sh")
4158            .arg("-c")
4159            .arg(format!("echo hi > {}", inside_file.display()))
4160            .status()
4161            .expect("failed to run sandbox-exec");
4162        assert!(
4163            inside_status.success(),
4164            "expected write inside session_cwd to succeed"
4165        );
4166        assert!(inside_file.exists(), "expected inside file to be created");
4167
4168        let dev_null_status = Command::new("sandbox-exec")
4169            .arg("-f")
4170            .arg(&profile_path)
4171            .arg("/bin/sh")
4172            .arg("-c")
4173            .arg("echo hi > /dev/null 2>&1")
4174            .status()
4175            .expect("failed to run sandbox-exec");
4176        assert!(
4177            dev_null_status.success(),
4178            "ordinary shell redirects to /dev/null must succeed"
4179        );
4180
4181        let outside_file = outside.path().join(format!(
4182            "kranz_sandbox_should_fail_{}",
4183            uuid::Uuid::new_v4()
4184        ));
4185        let outside_status = Command::new("sandbox-exec")
4186            .arg("-f")
4187            .arg(&profile_path)
4188            .arg("/bin/sh")
4189            .arg("-c")
4190            .arg(format!("echo hi > {}", outside_file.display()))
4191            .status()
4192            .expect("failed to run sandbox-exec");
4193        assert!(
4194            !outside_status.success(),
4195            "expected write outside allowlist to be denied"
4196        );
4197        assert!(
4198            !outside_file.exists(),
4199            "denied write must not have created the file"
4200        );
4201    }
4202
4203    #[cfg(target_os = "macos")]
4204    #[test]
4205    fn sandbox_enforcement_macos_denies_authority_material_reads() {
4206        use std::process::Command;
4207
4208        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4209
4210        if !sandbox_exec_can_apply() {
4211            return;
4212        }
4213
4214        let repo = tempfile::tempdir().unwrap();
4215        let mission = repo.path().join(".kranz").join("missions").join("m-x");
4216        std::fs::create_dir_all(&mission).unwrap();
4217        let tmp = tempfile::tempdir().unwrap();
4218        let kranz_dir = repo.path().join(".kranz");
4219        for name in [
4220            "serve.token",
4221            "serve.read.token",
4222            "config.json",
4223            "domain-terms.local",
4224        ] {
4225            std::fs::write(kranz_dir.join(name), "secret").unwrap();
4226        }
4227        let public = repo.path().join("public.txt");
4228        std::fs::write(&public, "public").unwrap();
4229
4230        let profile = generate_profile(&inputs(repo.path(), &mission, tmp.path(), vec![]));
4231        let profile_dir = tempfile::tempdir().unwrap();
4232        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4233
4234        for name in [
4235            "serve.token",
4236            "serve.read.token",
4237            "config.json",
4238            "domain-terms.local",
4239        ] {
4240            let status = Command::new("sandbox-exec")
4241                .arg("-f")
4242                .arg(&profile_path)
4243                .arg("/bin/cat")
4244                .arg(kranz_dir.join(name))
4245                .status()
4246                .expect("failed to run sandbox-exec");
4247            assert!(
4248                !status.success(),
4249                "sandboxed read of .kranz/{name} must be denied"
4250            );
4251        }
4252
4253        // Ordinary repo reads keep working under the same profile.
4254        let output = Command::new("sandbox-exec")
4255            .arg("-f")
4256            .arg(&profile_path)
4257            .arg("/bin/cat")
4258            .arg(&public)
4259            .output()
4260            .expect("failed to run sandbox-exec");
4261        assert!(
4262            output.status.success(),
4263            "ordinary repo reads must keep working: {}",
4264            String::from_utf8_lossy(&output.stderr)
4265        );
4266        assert_eq!(String::from_utf8_lossy(&output.stdout), "public");
4267    }
4268
4269    #[cfg(target_os = "macos")]
4270    #[test]
4271    fn sandbox_enforcement_macos_denies_mission_metadata_writes() {
4272        use std::process::Command;
4273
4274        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4275
4276        if !sandbox_exec_can_apply() {
4277            return;
4278        }
4279
4280        // Checkout-mode shape: session_cwd is the repo root, an ANCESTOR of
4281        // the mission dir — the hostile case the write denies exist for.
4282        let repo = tempfile::tempdir().unwrap();
4283        let mission = repo.path().join(".kranz").join("missions").join("m-x");
4284        let runs = mission.join("runs");
4285        std::fs::create_dir_all(&runs).unwrap();
4286        let control = mission.join("control");
4287        std::fs::create_dir_all(&control).unwrap();
4288        let contract_home = runs.join("contract-home");
4289        std::fs::create_dir_all(&contract_home).unwrap();
4290        let events = mission.join("events.jsonl");
4291        let state = mission.join("state.json");
4292        let old_transcript = runs.join("run-old.jsonl");
4293        std::fs::write(&events, "{\"seq\":1}\n").unwrap();
4294        std::fs::write(&state, "{}").unwrap();
4295        std::fs::write(&old_transcript, "original\n").unwrap();
4296        let scratch = tempfile::tempdir().unwrap();
4297
4298        let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
4299        let profile_dir = tempfile::tempdir().unwrap();
4300        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4301
4302        // Engine-owned paths refuse writes — including a NEW runs/*.jsonl
4303        // (the transcript regex denies creation, not just modification).
4304        let denied_writes = [
4305            format!("echo tampered >> {}", events.display()),
4306            format!("echo tampered > {}", state.display()),
4307            format!("echo x > {}", control.join("approve.json").display()),
4308            format!("echo forged >> {}", old_transcript.display()),
4309            format!("echo forged > {}", runs.join("run-new.jsonl").display()),
4310        ];
4311        for write in denied_writes {
4312            let status = Command::new("sandbox-exec")
4313                .arg("-f")
4314                .arg(&profile_path)
4315                .arg("/bin/sh")
4316                .arg("-c")
4317                .arg(&write)
4318                .status()
4319                .expect("failed to run sandbox-exec");
4320            assert!(!status.success(), "write must be denied: {write}");
4321        }
4322        assert_eq!(std::fs::read_to_string(&events).unwrap(), "{\"seq\":1}\n");
4323        assert_eq!(std::fs::read_to_string(&state).unwrap(), "{}");
4324        assert_eq!(
4325            std::fs::read_to_string(&old_transcript).unwrap(),
4326            "original\n"
4327        );
4328        assert!(!runs.join("run-new.jsonl").exists());
4329        assert!(std::fs::read_dir(&control).unwrap().next().is_none());
4330
4331        // The session's own work continues under the same profile: the repo
4332        // tree, the private scratch, and runs/ SUBDIRECTORIES stay writable.
4333        let allowed_writes = [
4334            repo.path().join("src.txt"),
4335            scratch.path().join("notes.txt"),
4336            contract_home.join("out.txt"),
4337        ];
4338        for target in allowed_writes {
4339            let status = Command::new("sandbox-exec")
4340                .arg("-f")
4341                .arg(&profile_path)
4342                .arg("/bin/sh")
4343                .arg("-c")
4344                .arg(format!("echo ok > {}", target.display()))
4345                .status()
4346                .expect("failed to run sandbox-exec");
4347            assert!(
4348                status.success(),
4349                "write must be allowed: {}",
4350                target.display()
4351            );
4352            assert!(target.exists());
4353        }
4354    }
4355
4356    /// The live half of `sandbox_profile_denies_authority_material_writes`
4357    /// and `..._git_config_and_hook_writes...`: a real `sandbox-exec` run
4358    /// under a checkout-mode profile refuses the writes and keeps the
4359    /// session's own work going (H2, H11, H3 support).
4360    #[cfg(target_os = "macos")]
4361    #[test]
4362    fn sandbox_enforcement_macos_denies_authority_and_git_metadata_writes() {
4363        use std::process::Command;
4364
4365        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4366
4367        if !sandbox_exec_can_apply() {
4368            return;
4369        }
4370
4371        let (repo, mission) = authority_write_fixture();
4372        let scratch = tempfile::tempdir().unwrap();
4373        let kranz = repo.path().join(".kranz");
4374        let git = repo.path().join(".git");
4375        std::fs::write(git.join("index"), "idx").unwrap();
4376
4377        let profile = generate_profile(&inputs(repo.path(), &mission, scratch.path(), vec![]));
4378        let profile_dir = tempfile::tempdir().unwrap();
4379        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4380
4381        let denied = [
4382            // The trust root: overwritten without ever being read.
4383            format!("echo '{{}}' > {}", kranz.join("config.json").display()),
4384            // A sibling mission's control inbox (forged operator consent).
4385            format!(
4386                "echo x > {}",
4387                kranz
4388                    .join("missions")
4389                    .join("m-other")
4390                    .join("approve.json")
4391                    .display()
4392            ),
4393            // A NEW sibling mission dir, and a NEW repo-level store.
4394            format!(
4395                "mkdir {}",
4396                kranz.join("missions").join("m-forged").display()
4397            ),
4398            format!("mkdir {}", kranz.join("newstore").display()),
4399            // Repo-level engine stores.
4400            format!("echo x > {}", kranz.join("queue").join("q.json").display()),
4401            format!("echo x > {}", kranz.join("lessons").join("l.md").display()),
4402            // The git hook and config surface the engine's next checkpoint
4403            // commit would execute.
4404            format!(
4405                "echo x > {}",
4406                git.join("hooks").join("pre-commit").display()
4407            ),
4408            format!("echo x > {}", git.join("config").display()),
4409        ];
4410        for command in &denied {
4411            let status = Command::new("sandbox-exec")
4412                .arg("-f")
4413                .arg(&profile_path)
4414                .arg("/bin/sh")
4415                .arg("-c")
4416                .arg(command)
4417                .status()
4418                .expect("failed to run sandbox-exec");
4419            assert!(!status.success(), "write must be denied: {command}");
4420        }
4421        assert_eq!(
4422            std::fs::read_to_string(kranz.join("config.json")).unwrap(),
4423            "secret"
4424        );
4425        assert!(!kranz.join("missions").join("m-forged").exists());
4426        assert!(!kranz.join("newstore").exists());
4427        assert!(!git.join("hooks").join("pre-commit").exists());
4428
4429        // The session's own work is untouched: the repo tree, the private
4430        // scratch, its own mission scratch under runs/, and the git index
4431        // the worker's own `git commit` writes.
4432        let allowed = [
4433            repo.path().join("src.txt"),
4434            scratch.path().join("notes.txt"),
4435            mission.join("runs").join("scratch").join("out.txt"),
4436            git.join("index"),
4437        ];
4438        for target in allowed {
4439            let status = Command::new("sandbox-exec")
4440                .arg("-f")
4441                .arg(&profile_path)
4442                .arg("/bin/sh")
4443                .arg("-c")
4444                .arg(format!("echo ok > {}", target.display()))
4445                .status()
4446                .expect("failed to run sandbox-exec");
4447            assert!(
4448                status.success(),
4449                "write must be allowed: {}",
4450                target.display()
4451            );
4452        }
4453    }
4454
4455    #[cfg(target_os = "macos")]
4456    #[test]
4457    fn sandbox_enforcement_macos_denies_sibling_temp_neighbors() {
4458        use std::process::Command;
4459
4460        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4461
4462        if !sandbox_exec_can_apply() {
4463            return;
4464        }
4465
4466        // The finding's layout: every mission's integration/feature
4467        // worktrees and scratch homes sit side by side under the shared
4468        // temp root. A session must write its own worktree + scratch and
4469        // nothing beside them.
4470        let root = tempfile::tempdir().unwrap();
4471        let session = root.path().join("kranz-wt-aaa-m1-f-1-1");
4472        let scratch = root.path().join("kranz-worker-home-sess-1");
4473        let scratch_home = scratch.join("home");
4474        let sibling = root.path().join("kranz-wt-bbb-m2-_integration");
4475        let sibling_scratch = root.path().join("kranz-worker-home-sess-2");
4476        for d in [&session, &scratch_home, &sibling, &sibling_scratch] {
4477            std::fs::create_dir_all(d).unwrap();
4478        }
4479        let mission = tempfile::tempdir().unwrap();
4480
4481        let profile = generate_profile(&inputs(&session, mission.path(), &scratch, vec![]));
4482        let profile_dir = tempfile::tempdir().unwrap();
4483        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4484
4485        for allowed in [session.join("code.rs"), scratch_home.join("notes.txt")] {
4486            let status = Command::new("sandbox-exec")
4487                .arg("-f")
4488                .arg(&profile_path)
4489                .arg("/bin/sh")
4490                .arg("-c")
4491                .arg(format!("echo ok > {}", allowed.display()))
4492                .status()
4493                .expect("failed to run sandbox-exec");
4494            assert!(
4495                status.success(),
4496                "write inside the session's own roots must be allowed: {}",
4497                allowed.display()
4498            );
4499            assert!(allowed.exists());
4500        }
4501
4502        for denied in [
4503            sibling.join("evil.txt"),
4504            sibling_scratch.join("evil.txt"),
4505            root.path().join("evil.txt"),
4506        ] {
4507            let status = Command::new("sandbox-exec")
4508                .arg("-f")
4509                .arg(&profile_path)
4510                .arg("/bin/sh")
4511                .arg("-c")
4512                .arg(format!("echo evil > {}", denied.display()))
4513                .status()
4514                .expect("failed to run sandbox-exec");
4515            assert!(
4516                !status.success(),
4517                "write to a temp neighbor must be denied: {}",
4518                denied.display()
4519            );
4520            assert!(!denied.exists());
4521        }
4522    }
4523
4524    #[cfg(target_os = "macos")]
4525    #[test]
4526    fn sandbox_enforcement_macos_fs_net_loopback_profile_applies() {
4527        use std::process::Command;
4528
4529        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
4530
4531        if !sandbox_exec_can_apply() {
4532            return;
4533        }
4534
4535        let session = tempfile::tempdir().unwrap();
4536        let mission = tempfile::tempdir().unwrap();
4537        let tmp = tempfile::tempdir().unwrap();
4538        let mut inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
4539        inputs.enforce = crate::types::SandboxEnforce::FsNet;
4540
4541        // The fs+net profile (loopback-only egress) must be ACCEPTED by
4542        // sandbox-exec — unlike the hostname-rule shape Seatbelt rejects with
4543        // "host must be * or localhost" — or fs+net sessions could not run.
4544        let profile = generate_profile(&inputs);
4545        let profile_dir = tempfile::tempdir().unwrap();
4546        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
4547
4548        let applied = Command::new("sandbox-exec")
4549            .arg("-f")
4550            .arg(&profile_path)
4551            .arg("/usr/bin/true")
4552            .output()
4553            .expect("failed to run sandbox-exec");
4554        assert!(
4555            applied.status.success(),
4556            "loopback-only fs+net profile must apply cleanly on macOS: {}",
4557            String::from_utf8_lossy(&applied.stderr)
4558        );
4559    }
4560
4561    #[cfg(target_os = "linux")]
4562    #[test]
4563    fn sandbox_enforcement_linux_bwrap_allows_inside_denies_outside() {
4564        use std::process::Command;
4565
4566        if !bwrap_can_apply() {
4567            return;
4568        }
4569
4570        let session = tempfile::tempdir().unwrap();
4571        let mission = tempfile::tempdir().unwrap();
4572        let tmp = tempfile::tempdir().unwrap();
4573        let outside = tempfile::tempdir().unwrap();
4574        let inputs = inputs(session.path(), mission.path(), tmp.path(), vec![]);
4575
4576        let inside_file = session.path().join("inside.txt");
4577        let inside_args = bubblewrap_args(
4578            &inputs,
4579            Path::new("/bin/sh"),
4580            &["-c".into(), format!("echo hi > {}", inside_file.display())],
4581        )
4582        .unwrap();
4583        let inside_status = Command::new("bwrap")
4584            .args(inside_args)
4585            .status()
4586            .expect("failed to run bwrap");
4587        assert!(
4588            inside_status.success(),
4589            "expected write inside session_cwd to succeed"
4590        );
4591        assert!(inside_file.exists(), "expected inside file to be created");
4592
4593        let outside_file = outside
4594            .path()
4595            .join(format!("kranz_bwrap_should_fail_{}", uuid::Uuid::new_v4()));
4596        let outside_args = bubblewrap_args(
4597            &inputs,
4598            Path::new("/bin/sh"),
4599            &["-c".into(), format!("echo hi > {}", outside_file.display())],
4600        )
4601        .unwrap();
4602        let outside_status = Command::new("bwrap")
4603            .args(outside_args)
4604            .status()
4605            .expect("failed to run bwrap");
4606        assert!(
4607            !outside_status.success(),
4608            "expected write outside allowlist to be denied"
4609        );
4610        assert!(
4611            !outside_file.exists(),
4612            "denied write must not have created the file"
4613        );
4614    }
4615
4616    #[cfg(target_os = "linux")]
4617    #[test]
4618    fn sandbox_enforcement_linux_bwrap_tolerates_disappearing_visible_entries() {
4619        if crate::agent_env::isolated_global_home_test(
4620            "sandbox::tests::sandbox_enforcement_linux_bwrap_tolerates_disappearing_visible_entries",
4621        ) {
4622            return;
4623        }
4624        if !bwrap_can_apply() {
4625            return;
4626        }
4627        let repo = tempfile::tempdir().unwrap();
4628        let scratch = tempfile::tempdir().unwrap();
4629        let home = tempfile::tempdir().unwrap();
4630        let _env =
4631            crate::agent_env::EnvTestGuard::engage(&[("HOME", home.path().to_str().unwrap())]);
4632        let kranz = home.path().join(".kranz");
4633        let mission = repo.path().join(".kranz/missions/m-mask-race");
4634        std::fs::create_dir_all(&mission).unwrap();
4635        let transient_dir = home.path().join("temporary-cache");
4636        let transient_file = home.path().join("temporary-note");
4637        let public = home.path().join("public.txt");
4638        let authority_path = repo.path().join(".kranz/serve.token");
4639        let late_authority_path = kranz.join("serve.read.token");
4640        let writable = repo.path().join("result.txt");
4641        std::fs::create_dir(&transient_dir).unwrap();
4642        std::fs::write(&transient_file, "temporary").unwrap();
4643        std::fs::write(&public, "public").unwrap();
4644        std::fs::write(&authority_path, "secret").unwrap();
4645        let args = bubblewrap_args(
4646            &inputs(repo.path(), &mission, scratch.path(), vec![]),
4647            Path::new("/bin/sh"),
4648            &[
4649                "-c".into(),
4650                "test ! -e \"$1\" && test ! -e \"$2\" \
4651                 && test \"$(cat \"$3\")\" = public && ! touch \"$3\" \
4652                 && test ! -e \"$4\" && test ! -e \"$5\" \
4653                 && printf ok > \"$6\""
4654                    .into(),
4655                "mask-race".into(),
4656                transient_dir.display().to_string(),
4657                transient_file.display().to_string(),
4658                public.display().to_string(),
4659                authority_path.display().to_string(),
4660                late_authority_path.display().to_string(),
4661                writable.display().to_string(),
4662            ],
4663        )
4664        .unwrap();
4665        for path in [&transient_dir, &transient_file] {
4666            let path = absolutize(path).display().to_string();
4667            assert!(args.windows(3).any(|part| {
4668                matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
4669                    && part[1] == path
4670                    && part[2] == path
4671            }));
4672        }
4673        // Deterministically reproduce deletion between enumeration and mount
4674        // setup, while also creating authority that the private view must hide.
4675        std::fs::remove_dir(&transient_dir).unwrap();
4676        std::fs::remove_file(&transient_file).unwrap();
4677        std::fs::create_dir(&kranz).unwrap();
4678        std::fs::write(&late_authority_path, "late-secret").unwrap();
4679        let output = std::process::Command::new("bwrap")
4680            .args(args)
4681            .env_clear()
4682            .env("PATH", "/usr/bin:/bin")
4683            .output()
4684            .unwrap();
4685        assert!(
4686            output.status.success(),
4687            "missing ordinary entries must stay hidden without breaking the sandbox: {output:?}"
4688        );
4689        assert_eq!(std::fs::read_to_string(&writable).unwrap(), "ok");
4690        assert_eq!(std::fs::read_to_string(&public).unwrap(), "public");
4691        assert_eq!(std::fs::read_to_string(&authority_path).unwrap(), "secret");
4692        assert_eq!(
4693            std::fs::read_to_string(&late_authority_path).unwrap(),
4694            "late-secret"
4695        );
4696    }
4697
4698    #[cfg(unix)]
4699    #[test]
4700    fn sandbox_bwrap_rebinding_keeps_git_and_authority_protected() {
4701        if crate::agent_env::isolated_global_home_test(
4702            "sandbox::tests::sandbox_bwrap_rebinding_keeps_git_and_authority_protected",
4703        ) {
4704            return;
4705        }
4706        let dir = tempfile::tempdir().unwrap();
4707        let home = dir.path().canonicalize().unwrap();
4708        let cargo = home.join(".cargo");
4709        std::fs::create_dir(home.join(".kranz")).unwrap();
4710        let _env = crate::agent_env::EnvTestGuard::engage(&[
4711            ("HOME", home.to_str().unwrap()),
4712            ("CARGO_HOME", cargo.to_str().unwrap()),
4713        ]);
4714        // The absent Cargo home promotes its authority mask to HOME, above
4715        // the checkout. A validator snapshot also gets rebound below runs/.
4716        assert!(!cargo.exists());
4717        for snapshot in [false, true] {
4718            let repo = home.join(if snapshot {
4719                "validator-repo"
4720            } else {
4721                "checkout"
4722            });
4723            let mission = repo.join(".kranz/missions/m-rebind");
4724            let scratch = mission.join("runs/scratch");
4725            let cwd = if snapshot {
4726                mission.join("runs/snapshot")
4727            } else {
4728                repo.clone()
4729            };
4730            for path in [&cwd, &scratch] {
4731                std::fs::create_dir_all(path).unwrap();
4732            }
4733            let protected = if snapshot {
4734                vec![cwd.join(".git")]
4735            } else {
4736                std::fs::create_dir_all(cwd.join(".git/hooks")).unwrap();
4737                vec![cwd.join(".git/config"), cwd.join(".git/hooks/probe")]
4738            };
4739            for path in &protected {
4740                std::fs::write(path, "protected").unwrap();
4741            }
4742            let authority_path = repo.join(".kranz/serve.token");
4743            std::fs::write(&authority_path, "secret").unwrap();
4744            let ordinary = if snapshot {
4745                cwd.join("witness")
4746            } else {
4747                cwd.join(".git/index")
4748            };
4749            let mut command = vec![
4750                "-c".into(),
4751                "printf work > \"$1\" || exit 1; printf work > \"$2\" || exit 2; \
4752                 if cat \"$3\"; then exit 3; fi; shift 3; \
4753                 for path in \"$@\"; do \
4754                 test \"$(cat \"$path\")\" = protected || exit 4; \
4755                 if printf forged > \"$path\"; then exit 5; fi; done"
4756                    .into(),
4757                "rebind-test".into(),
4758                ordinary.display().to_string(),
4759                scratch.join("witness").display().to_string(),
4760                authority_path.display().to_string(),
4761            ];
4762            command.extend(protected.iter().map(|path| path.display().to_string()));
4763            let args = bubblewrap_args(
4764                &inputs(&cwd, &mission, &scratch, vec![]),
4765                Path::new("/bin/sh"),
4766                &command,
4767            )
4768            .unwrap();
4769            for path in &protected {
4770                let last_bind = args.windows(3).rev().find(|part| {
4771                    matches!(part[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try")
4772                        && path.starts_with(&part[2])
4773                });
4774                assert_eq!(
4775                    last_bind.map(|part| part[0].as_str()),
4776                    Some("--ro-bind"),
4777                    "a later writable ancestor reopened {}: {args:?}",
4778                    path.display()
4779                );
4780            }
4781            #[cfg(target_os = "linux")]
4782            if bwrap_can_apply() {
4783                let output = std::process::Command::new("bwrap")
4784                    .args(&args)
4785                    .env_clear()
4786                    .env("PATH", "/usr/bin:/bin")
4787                    .output()
4788                    .unwrap();
4789                assert!(output.status.success(), "snapshot={snapshot}: {output:?}");
4790                assert_eq!(std::fs::read_to_string(&ordinary).unwrap(), "work");
4791                assert_eq!(std::fs::read_to_string(&authority_path).unwrap(), "secret");
4792                for path in protected {
4793                    assert_eq!(std::fs::read_to_string(path).unwrap(), "protected");
4794                }
4795            }
4796        }
4797    }
4798
4799    #[cfg(target_os = "linux")]
4800    #[test]
4801    fn sandbox_enforcement_linux_bwrap_masks_authority_material() {
4802        if crate::agent_env::isolated_global_home_test(
4803            "sandbox::tests::sandbox_enforcement_linux_bwrap_masks_authority_material",
4804        ) {
4805            return;
4806        }
4807        use std::process::Command;
4808
4809        if !bwrap_can_apply() {
4810            return;
4811        }
4812
4813        let repo = tempfile::tempdir().unwrap();
4814        let mission = repo.path().join(".kranz").join("missions").join("m-x");
4815        std::fs::create_dir_all(&mission).unwrap();
4816        let tmp = tempfile::tempdir().unwrap();
4817        let serve_token = repo.path().join(".kranz").join("serve.token");
4818        std::fs::write(&serve_token, "secret").unwrap();
4819        let public = repo.path().join("public.txt");
4820        std::fs::write(&public, "public").unwrap();
4821        let home = tempfile::tempdir().unwrap();
4822        let authority_target = tempfile::tempdir().unwrap();
4823        let alias = home.path().join(".kranz");
4824        std::os::unix::fs::symlink(authority_target.path(), &alias).unwrap();
4825        let config_target = authority_target.path().join("settings.json");
4826        std::fs::write(&config_target, "config-secret").unwrap();
4827        std::os::unix::fs::symlink(&config_target, repo.path().join(".kranz/config.json")).unwrap();
4828        let _env =
4829            crate::agent_env::EnvTestGuard::engage(&[("HOME", home.path().to_str().unwrap())]);
4830        let inputs = inputs(repo.path(), &mission, tmp.path(), vec![home.path().into()]);
4831
4832        // The private directory has no authority entry at all.
4833        let masked = Command::new("bwrap")
4834            .args(
4835                bubblewrap_args(
4836                    &inputs,
4837                    Path::new("/bin/cat"),
4838                    &[serve_token.display().to_string()],
4839                )
4840                .unwrap(),
4841            )
4842            .output()
4843            .expect("failed to run bwrap");
4844        assert!(
4845            !masked.status.success(),
4846            "reading the hidden authority path must fail: {}",
4847            String::from_utf8_lossy(&masked.stderr)
4848        );
4849        assert!(
4850            !String::from_utf8_lossy(&masked.stdout).contains("secret"),
4851            "serve.token content must be masked inside the sandbox"
4852        );
4853
4854        let late = repo.path().join(".kranz/serve.read.token");
4855        let args = bubblewrap_args(
4856            &inputs,
4857            Path::new("/bin/cat"),
4858            &[late.display().to_string()],
4859        )
4860        .unwrap();
4861        // Create the token after the mount policy has been resolved. The old
4862        // existence-filtered file mask would have exposed this value.
4863        std::fs::write(&late, "late-secret").unwrap();
4864        let output = Command::new("bwrap").args(args).output().unwrap();
4865        assert!(!output.status.success());
4866        assert!(!String::from_utf8_lossy(&output.stdout).contains("late-secret"));
4867
4868        let host_view = format!("/proc/{}/root{}", std::process::id(), serve_token.display());
4869        let output = Command::new("bwrap")
4870            .args(bubblewrap_args(&inputs, Path::new("/bin/cat"), &[host_view]).unwrap())
4871            .output()
4872            .unwrap();
4873        assert!(
4874            !output.status.success(),
4875            "host /proc roots must not bypass the namespace"
4876        );
4877        assert!(!String::from_utf8_lossy(&output.stdout).contains("secret"));
4878
4879        for (binary, path) in [("/bin/cat", &config_target), ("/bin/rm", &alias)] {
4880            let output = Command::new("bwrap")
4881                .args(
4882                    bubblewrap_args(&inputs, Path::new(binary), &[path.display().to_string()])
4883                        .unwrap(),
4884                )
4885                .output()
4886                .unwrap();
4887            assert!(
4888                !output.status.success(),
4889                "authority alias/target was exposed: {output:?}"
4890            );
4891        }
4892        assert!(
4893            alias.is_symlink(),
4894            "the operator's authority alias was replaced"
4895        );
4896
4897        let control = Command::new("bwrap")
4898            .args(
4899                bubblewrap_args(
4900                    &inputs,
4901                    Path::new("/bin/cat"),
4902                    &[public.display().to_string()],
4903                )
4904                .unwrap(),
4905            )
4906            .output()
4907            .expect("failed to run bwrap");
4908        assert_eq!(String::from_utf8_lossy(&control.stdout), "public");
4909    }
4910
4911    // -----------------------------------------------------------------------
4912    // Mandatory validator containment (ticket validator-mandatory-containment)
4913    // -----------------------------------------------------------------------
4914
4915    /// A fake real-checkout root in the exact production layout: a source
4916    /// tree (dir + files, including a dotfile secret), the shared `.git`
4917    /// dir, and the `.kranz` mission layout with the validator's snapshot
4918    /// worktree underneath. Returns (tempdir guard, root, snapshot, mission).
4919    fn validator_containment_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
4920        let dir = tempfile::tempdir().unwrap();
4921        let root = dir.path().join("repo");
4922        std::fs::create_dir_all(root.join("src")).unwrap();
4923        std::fs::write(root.join("src").join("secret.rs"), "fn secret() {}\n").unwrap();
4924        std::fs::write(root.join("Cargo.toml"), "[package]\n").unwrap();
4925        // The .env is authority material by NAME (path-based deny); its
4926        // content is irrelevant to the test and deliberately not
4927        // secret-shaped (the range scanner fires on TOKEN= shapes — the
4928        // path is bound separately so no .env + value adjacency exists).
4929        let dotenv_path = root.join(".env");
4930        std::fs::write(&dotenv_path, "placeholder-content\n").unwrap();
4931        std::fs::create_dir_all(root.join(".git")).unwrap();
4932        std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
4933        let mission = root.join(".kranz").join("missions").join("m-x");
4934        let snapshot = mission.join("runs").join("validator-snapshot-scrutiny");
4935        std::fs::create_dir_all(&snapshot).unwrap();
4936        std::fs::write(snapshot.join("README.md"), "snapshot copy\n").unwrap();
4937        std::fs::write(root.join(".kranz").join("serve.token"), "secret-token").unwrap();
4938        // The sensitive .kranz runtime the 14th-pass over-read finding names
4939        // (ticket validator-containment-kranz-overread): the plaintext lint
4940        // vocabulary, the hook-status projection, and the mission control
4941        // inbox — all reachable through the .kranz carve-out unless the
4942        // authority read-deny set covers them.
4943        std::fs::write(
4944            root.join(".kranz").join("domain-terms.local"),
4945            "acme widget\n",
4946        )
4947        .unwrap();
4948        let hook_status = root.join(".kranz").join("hook-status").join("m-x");
4949        std::fs::create_dir_all(&hook_status).unwrap();
4950        std::fs::write(hook_status.join("run-1.json"), "{\"tokenHash\":\"abc\"}\n").unwrap();
4951        let control = mission.join("control");
4952        std::fs::create_dir_all(&control).unwrap();
4953        std::fs::write(control.join("approve.json"), "{}\n").unwrap();
4954        (dir, root, snapshot, mission)
4955    }
4956
4957    /// A REAL git repo in the same layout (one committed file + a committed
4958    /// `src/` dir, `.kranz/` ignored, the snapshot as a detached worktree
4959    /// under the mission's `runs/`) for the applied probes that exercise
4960    /// the git surface. None when git is not on PATH (mirrors the
4961    /// orchestrator tests' `lessons_test_repo` skip).
4962    #[cfg(any(target_os = "macos", target_os = "linux"))]
4963    fn validator_containment_git_fixture() -> Option<(tempfile::TempDir, PathBuf, PathBuf, PathBuf)>
4964    {
4965        let git_ok = std::process::Command::new("git")
4966            .arg("--version")
4967            .output()
4968            .map(|o| o.status.success())
4969            .unwrap_or(false);
4970        if !git_ok {
4971            crate::test_capability::skip(
4972                crate::test_capability::capability::GIT,
4973                "git is not on PATH",
4974            );
4975            return None;
4976        }
4977        let dir = tempfile::tempdir().unwrap();
4978        let root = dir.path().join("repo");
4979        std::fs::create_dir_all(&root).unwrap();
4980        let run = |args: &[&str]| {
4981            let out = std::process::Command::new("git")
4982                .args(args)
4983                .current_dir(&root)
4984                .output()
4985                .expect("spawn git");
4986            assert!(out.status.success(), "git {args:?} failed: {out:?}");
4987        };
4988        if !std::process::Command::new("git")
4989            .args(["init", "-b", "main"])
4990            .current_dir(&root)
4991            .output()
4992            .map(|o| o.status.success())
4993            .unwrap_or(false)
4994        {
4995            run(&["init"]);
4996            run(&["symbolic-ref", "HEAD", "refs/heads/main"]);
4997        }
4998        run(&["config", "user.name", "test"]);
4999        run(&["config", "user.email", "test@example.com"]);
5000        std::fs::create_dir_all(root.join("src")).unwrap();
5001        std::fs::write(root.join("src").join("secret.rs"), "fn secret() {}\n").unwrap();
5002        std::fs::write(root.join("tracked.rs"), "fn tracked() {}\n").unwrap();
5003        std::fs::write(root.join(".gitignore"), ".kranz/\n").unwrap();
5004        run(&["add", "-A"]);
5005        run(&["commit", "-m", "init"]);
5006        let mission = root.join(".kranz").join("missions").join("m-x");
5007        let snapshot = mission.join("runs").join("validator-snapshot-scrutiny");
5008        std::fs::create_dir_all(snapshot.parent().unwrap()).unwrap();
5009        run(&[
5010            "worktree",
5011            "add",
5012            "--detach",
5013            snapshot.to_str().expect("utf-8 temp path"),
5014        ]);
5015        // Engine-owned metadata + the authority material the denies cover.
5016        std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
5017        std::fs::write(root.join(".kranz").join("serve.token"), "secret-token").unwrap();
5018        Some((dir, root, snapshot, mission))
5019    }
5020
5021    /// The mandatory-wrap inputs shape: the snapshot as the sole writable
5022    /// session root, the real checkout as the read-deny root.
5023    fn validator_containment_inputs(
5024        root: &Path,
5025        snapshot: &Path,
5026        mission: &Path,
5027        tmpdir: &Path,
5028    ) -> SandboxInputs {
5029        SandboxInputs {
5030            enforce: crate::types::SandboxEnforce::Fs,
5031            session_cwd: snapshot.to_path_buf(),
5032            mission_dir: mission.to_path_buf(),
5033            tmpdir: tmpdir.to_path_buf(),
5034            extra_write: Vec::new(),
5035            egress: Vec::new(),
5036            validator_read_deny_roots: vec![root.to_path_buf()],
5037        }
5038    }
5039
5040    /// The read-deny set covers the whole source tree — dirs classified as
5041    /// dirs, files as files, raw AND canonical forms — and NEVER names the
5042    /// `.git`/`.kranz` carve-outs.
5043    #[test]
5044    fn validator_containment_entries_cover_source_tree_and_carve_out_git_and_kranz() {
5045        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5046        let scratch = tempfile::tempdir().unwrap();
5047        let inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5048        let entries = validator_read_deny_entries(&inputs);
5049
5050        for base in [root.clone(), absolutize(&root)] {
5051            let src = base.join("src");
5052            assert!(
5053                entries.contains(&ValidatorReadDenyEntry {
5054                    path: src.clone(),
5055                    is_dir: true
5056                }),
5057                "src/ must be a denied dir: {entries:?}"
5058            );
5059            for file in ["Cargo.toml", ".env"] {
5060                assert!(
5061                    entries.contains(&ValidatorReadDenyEntry {
5062                        path: base.join(file),
5063                        is_dir: false
5064                    }),
5065                    "{file} must be a denied file: {entries:?}"
5066                );
5067            }
5068        }
5069        assert!(
5070            entries.iter().all(|e| e
5071                .path
5072                .file_name()
5073                .is_some_and(|n| n != ".git" && n != ".kranz")),
5074            "the carve-outs must never be denied: {entries:?}"
5075        );
5076    }
5077
5078    /// The generated profile: a second read-deny block closes the broad read
5079    /// allow over the real checkout (dirs as subpaths, files and the root
5080    /// itself as literals) while the snapshot stays writable and the shared
5081    /// git dir + mission dir stay reachable.
5082    #[test]
5083    fn validator_containment_profile_read_denies_source_tree_and_keeps_carveouts() {
5084        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5085        let scratch = tempfile::tempdir().unwrap();
5086        let profile = generate_profile(&validator_containment_inputs(
5087            &root,
5088            &snapshot,
5089            &mission,
5090            scratch.path(),
5091        ));
5092        let read_rules: String = profile
5093            .split("(deny file-read*")
5094            .skip(1)
5095            .map(|block| block.split("\n)\n").next().unwrap_or_default())
5096            .collect();
5097
5098        for base in [root.clone(), absolutize(&root)] {
5099            let src = format!("(subpath \"{}\")", escape_sbpl_literal(&base.join("src")));
5100            assert!(
5101                profile.contains(&src),
5102                "profile missing read deny for src/:\n{profile}"
5103            );
5104            for file in ["Cargo.toml", ".env"] {
5105                let lit = format!("(literal \"{}\")", escape_sbpl_literal(&base.join(file)));
5106                assert!(
5107                    profile.contains(&lit),
5108                    "profile missing read deny for {file}:\n{profile}"
5109                );
5110            }
5111            let root_lit = format!("(literal \"{}\")", escape_sbpl_literal(&base));
5112            assert!(
5113                !read_rules.contains(&root_lit),
5114                "the root itself is deliberately NOT denied (a literal deny breaks \
5115                 coreutils `mkdir -p`, which stats every ancestor):\n{profile}"
5116            );
5117            // The carve-outs are never denied: no rule names the .git or
5118            // .kranz DIRS themselves (the closing quote makes this exact).
5119            let git_rule = format!("\"{}\"", escape_sbpl_literal(&base.join(".git")));
5120            assert!(
5121                !read_rules.contains(&git_rule),
5122                ".git must stay readable (the inspection's git surface):\n{profile}"
5123            );
5124            let kranz_rule = format!("\"{}\"", escape_sbpl_literal(&base.join(".kranz")));
5125            assert!(
5126                !read_rules.contains(&kranz_rule),
5127                ".kranz must stay reachable (the snapshot lives under it):\n{profile}"
5128            );
5129        }
5130        // …and the .kranz carve-out does not reopen the authority material.
5131        for base in [root.join(".kranz"), absolutize(&root.join(".kranz"))] {
5132            let token = format!(
5133                "(literal \"{}\")",
5134                escape_sbpl_literal(&base.join("serve.token"))
5135            );
5136            assert!(
5137                profile.contains(&token),
5138                "the authority read deny must survive the carve-out:\n{profile}"
5139            );
5140        }
5141        // The snapshot stays the writable root.
5142        let snap_rule = format!(
5143            "(subpath \"{}\")",
5144            escape_sbpl_literal(&absolutize(&snapshot))
5145        );
5146        assert!(
5147            profile.contains(&snap_rule),
5148            "the snapshot must stay writable:\n{profile}"
5149        );
5150        // …and /dev/null stays writable (the gate wrap's documented finding:
5151        // git and the shell open it O_RDWR in ordinary operation).
5152        assert!(
5153            profile.contains("(allow file-write* (literal \"/dev/null\"))"),
5154            "validator profiles must keep /dev/null writable:\n{profile}"
5155        );
5156    }
5157
5158    /// 14th-pass review (ticket `validator-containment-kranz-overread`): the
5159    /// `.kranz` carve-out the snapshot lives under must not reopen the
5160    /// sensitive runtime beneath it — the plaintext lint vocabulary
5161    /// (`domain-terms.local`), the hook-status projection, and the mission
5162    /// control inbox are read-denied (literal for the file, subpaths for the
5163    /// dirs) in BOTH raw and canonical forms, exactly like the serve-token
5164    /// authority material.
5165    #[test]
5166    fn validator_containment_profile_denies_sensitive_kranz_runtime_reads() {
5167        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5168        let scratch = tempfile::tempdir().unwrap();
5169        let profile = generate_profile(&validator_containment_inputs(
5170            &root,
5171            &snapshot,
5172            &mission,
5173            scratch.path(),
5174        ));
5175        let read_rules: String = profile
5176            .split("(deny file-read*")
5177            .skip(1)
5178            .map(|block| block.split("\n)\n").next().unwrap_or_default())
5179            .collect();
5180
5181        let kranz = root.join(".kranz");
5182        for base in [kranz.clone(), absolutize(&kranz)] {
5183            let terms = format!(
5184                "(literal \"{}\")",
5185                escape_sbpl_literal(&base.join("domain-terms.local"))
5186            );
5187            assert!(
5188                profile.contains(&terms),
5189                "profile missing read deny for domain-terms.local:\n{profile}"
5190            );
5191            let hook = format!(
5192                "(subpath \"{}\")",
5193                escape_sbpl_literal(&base.join("hook-status"))
5194            );
5195            assert!(
5196                profile.contains(&hook),
5197                "profile missing read deny for hook-status/:\n{profile}"
5198            );
5199        }
5200        for base in [mission.clone(), absolutize(&mission)] {
5201            let control = format!(
5202                "(subpath \"{}\")",
5203                escape_sbpl_literal(&base.join("control"))
5204            );
5205            assert!(
5206                profile.contains(&control),
5207                "profile missing read deny for the control inbox:\n{profile}"
5208            );
5209        }
5210        // …while the carve-out itself stays: no deny names the .kranz DIR
5211        // (the closing quote makes this exact).
5212        for base in [kranz.clone(), absolutize(&kranz)] {
5213            let kranz_rule = format!("\"{}\"", escape_sbpl_literal(&base));
5214            assert!(
5215                !read_rules.contains(&kranz_rule),
5216                ".kranz must stay reachable (the snapshot lives under it):\n{profile}"
5217            );
5218        }
5219    }
5220
5221    /// Non-validator sessions (empty roots) get byte-stable profiles: exactly
5222    /// the pre-containment shape, i.e. only the authority read-deny block.
5223    #[test]
5224    fn validator_containment_empty_roots_emit_no_deny_block() {
5225        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5226        let scratch = tempfile::tempdir().unwrap();
5227        let mut inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5228        inputs.validator_read_deny_roots = Vec::new();
5229        let profile = generate_profile(&inputs);
5230        assert_eq!(
5231            profile.matches("(deny file-read*").count(),
5232            1,
5233            "empty roots must leave the pre-containment profile shape alone:\n{profile}"
5234        );
5235
5236        let profile = generate_profile(&validator_containment_inputs(
5237            &root,
5238            &snapshot,
5239            &mission,
5240            scratch.path(),
5241        ));
5242        assert_eq!(
5243            profile.matches("(deny file-read*").count(),
5244            2,
5245            "the validator read-deny block must land when roots are set:\n{profile}"
5246        );
5247    }
5248
5249    /// The bwrap analogue: source dirs shadowed by tmpfs, source files
5250    /// masked with /dev/null, carve-outs untouched, the snapshot rw-bound.
5251    #[test]
5252    fn validator_containment_bwrap_masks_source_tree_and_keeps_carveouts() {
5253        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5254        let scratch = tempfile::tempdir().unwrap();
5255        let args = bubblewrap_args(
5256            &validator_containment_inputs(&root, &snapshot, &mission, scratch.path()),
5257            Path::new("/usr/bin/claude"),
5258            &[],
5259        )
5260        .unwrap();
5261        let joined = args.join(" ");
5262
5263        let src = absolutize(&root.join("src")).display().to_string();
5264        assert!(
5265            args.windows(2).any(|w| w[0] == "--tmpfs" && w[1] == src),
5266            "missing tmpfs shadow for src/: {args:?}"
5267        );
5268        let env_file = absolutize(&root.join(".env")).display().to_string();
5269        assert!(
5270            joined.contains(&format!("--ro-bind /dev/null {env_file}")),
5271            "missing /dev/null mask for .env: {args:?}"
5272        );
5273        // The carve-outs are never masked, and the root itself is not
5274        // shadowed (bwrap cannot close the listing without hiding them).
5275        let git = absolutize(&root.join(".git")).display().to_string();
5276        assert!(
5277            !joined.contains(&git),
5278            ".git must not be masked (the inspection's git surface): {args:?}"
5279        );
5280        assert!(
5281            !args
5282                .windows(2)
5283                .any(|w| w[0] == "--tmpfs" && w[1] == root.display().to_string()),
5284            "the root itself must not be shadowed: {args:?}"
5285        );
5286        // The snapshot stays rw-bound.
5287        let snap = absolutize(&snapshot).display().to_string();
5288        assert!(
5289            joined.contains(&format!("--bind {snap} {snap}")),
5290            "the snapshot must stay rw-bound: {args:?}"
5291        );
5292    }
5293
5294    /// The bwrap analogue of the 14th-pass over-read fix (ticket
5295    /// `validator-containment-kranz-overread`): the plaintext lint
5296    /// vocabulary gets a `/dev/null` mask, and the hook-status projection +
5297    /// the control inbox get tmpfs shadows (the control/ shadow was already
5298    /// the write-deny idiom; the same mechanism now hides hook-status/).
5299    #[test]
5300    fn validator_containment_bwrap_masks_sensitive_kranz_runtime() {
5301        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5302        let scratch = tempfile::tempdir().unwrap();
5303        let args = bubblewrap_args(
5304            &validator_containment_inputs(&root, &snapshot, &mission, scratch.path()),
5305            Path::new("/usr/bin/claude"),
5306            &[],
5307        )
5308        .unwrap();
5309        let joined = args.join(" ");
5310
5311        let kranz = absolutize(&root.join(".kranz")).display().to_string();
5312        assert!(
5313            joined.contains(&format!("--tmpfs {kranz}")) && !joined.contains("domain-terms.local"),
5314            "the private authority directory must exclude domain-terms.local: {args:?}"
5315        );
5316        for dir in [
5317            root.join(".kranz").join("hook-status"),
5318            mission.join("control"),
5319        ] {
5320            let shadow = absolutize(dir.parent().unwrap()).display().to_string();
5321            assert!(args.windows(2).any(|w| w[0] == "--tmpfs" && w[1] == shadow));
5322            let denied = absolutize(&dir).display().to_string();
5323            assert!(
5324                !args.windows(3).any(|part| {
5325                    matches!(part[0].as_str(), "--ro-bind" | "--ro-bind-try")
5326                        && part[1] == denied
5327                        && part[2] == denied
5328                }),
5329                "denied directory must not be rebound: {args:?}"
5330            );
5331        }
5332    }
5333
5334    // --- the resolution matrix -----------------------------------------------
5335
5336    fn off_cfg() -> crate::types::SandboxConfig {
5337        crate::types::SandboxConfig::default()
5338    }
5339
5340    fn fs_cfg() -> crate::types::SandboxConfig {
5341        crate::types::SandboxConfig {
5342            enforce: crate::types::SandboxEnforce::Fs,
5343            ..crate::types::SandboxConfig::default()
5344        }
5345    }
5346
5347    /// The case the ticket exists for: `enforce: off` (the default) STILL
5348    /// wraps the validator on macOS — the mandatory fs-tier wrap with the
5349    /// real checkout read-denied and NO operator extraWrite widening.
5350    #[test]
5351    fn validator_containment_off_macos_wraps_mandatory_seatbelt() {
5352        let mut cfg = off_cfg();
5353        cfg.extra_write = vec!["~/elsewhere".to_string()];
5354        let roots = vec![PathBuf::from("/repo")];
5355        let containment = resolve_validator_containment_target(
5356            &cfg,
5357            crate::types::BackendKind::Claude,
5358            Path::new("/repo/.kranz/missions/m-x/runs/snap"),
5359            Path::new("/repo/.kranz/missions/m-x"),
5360            &roots,
5361            false,
5362            "macos",
5363            false,
5364            None,
5365            None,
5366        )
5367        .expect("off+macos resolves the mandatory wrap");
5368        assert!(containment.note.is_none(), "{:?}", containment.note);
5369        let sandbox = containment.sandbox.expect("a wrap applies");
5370        assert_eq!(sandbox.backend, SandboxBackend::Seatbelt);
5371        assert_eq!(
5372            sandbox.inputs.enforce,
5373            crate::types::SandboxEnforce::Fs,
5374            "the mandatory wrap is the fs tier (egress stays open for the API)"
5375        );
5376        assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5377        assert!(
5378            sandbox.inputs.extra_write.is_empty(),
5379            "no operator extraWrite widening under the mandatory wrap"
5380        );
5381        assert_eq!(
5382            sandbox.inputs.session_cwd,
5383            PathBuf::from("/repo/.kranz/missions/m-x/runs/snap"),
5384            "the snapshot is the writable root"
5385        );
5386    }
5387
5388    /// Linux: the mandatory wrap needs `bwrap`; without it the resolution
5389    /// FAILS CLOSED by default (naming the platform limit and the flag), and
5390    /// only the explicit `validatorAllowUncontainedDegrade` opt-in restores
5391    /// the loud degrade note (ticket
5392    /// validator-containment-degrade-fail-closed).
5393    #[test]
5394    fn validator_containment_off_linux_without_bwrap_fails_closed_unless_opted_in() {
5395        let roots = vec![PathBuf::from("/repo")];
5396        let err = resolve_validator_containment_target(
5397            &off_cfg(),
5398            crate::types::BackendKind::Claude,
5399            Path::new("/snap"),
5400            Path::new("/mission"),
5401            &roots,
5402            false,
5403            "linux",
5404            false,
5405            None,
5406            None,
5407        )
5408        .expect_err("no bwrap and no opt-in: fail closed");
5409        let err = err.to_string();
5410        assert!(err.contains("bwrap"), "{err}");
5411        assert!(err.contains("validatorAllowUncontainedDegrade"), "{err}");
5412        assert!(
5413            err.contains("refusing to run an uncontained validator"),
5414            "{err}"
5415        );
5416
5417        let containment = resolve_validator_containment_target(
5418            &off_cfg(),
5419            crate::types::BackendKind::Claude,
5420            Path::new("/snap"),
5421            Path::new("/mission"),
5422            &roots,
5423            true,
5424            "linux",
5425            false,
5426            None,
5427            None,
5428        )
5429        .expect("the opt-in restores the loud degrade");
5430        assert!(containment.sandbox.is_none());
5431        let note = containment.note.expect("the loud note");
5432        assert!(note.contains("bwrap"), "{note}");
5433        assert!(note.contains("validator-mandatory-containment"), "{note}");
5434
5435        let containment = resolve_validator_containment_target(
5436            &off_cfg(),
5437            crate::types::BackendKind::Claude,
5438            Path::new("/snap"),
5439            Path::new("/mission"),
5440            &roots,
5441            false,
5442            "linux",
5443            true,
5444            None,
5445            None,
5446        )
5447        .expect("off+linux+bwrap resolves");
5448        assert!(containment.note.is_none(), "{:?}", containment.note);
5449        assert_eq!(
5450            containment.sandbox.expect("a wrap applies").backend,
5451            SandboxBackend::Bubblewrap
5452        );
5453    }
5454
5455    /// M7 Windows parity, phase 4: validators resolve the same mandatory
5456    /// AppContainer fs-tier wrap as other containable platforms, regardless
5457    /// of the legacy uncontained-degrade opt-in.
5458    #[test]
5459    fn validator_containment_off_windows_resolves_appcontainer() {
5460        let roots = vec![PathBuf::from("C:\\repo")];
5461        for allow_uncontained_degrade in [false, true] {
5462            let containment = resolve_validator_containment_target(
5463                &off_cfg(),
5464                crate::types::BackendKind::Claude,
5465                Path::new("C:\\snap"),
5466                Path::new("C:\\mission"),
5467                &roots,
5468                allow_uncontained_degrade,
5469                "windows",
5470                false,
5471                None,
5472                None,
5473            )
5474            .expect("Windows resolves the mandatory AppContainer wrap");
5475            assert!(containment.note.is_none(), "{:?}", containment.note);
5476            let sandbox = containment.sandbox.expect("a wrap applies");
5477            assert_eq!(sandbox.backend, SandboxBackend::AppContainer);
5478            assert_eq!(sandbox.inputs.enforce, crate::types::SandboxEnforce::Fs);
5479            assert_eq!(sandbox.inputs.session_cwd, PathBuf::from("C:\\snap"));
5480            assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5481            assert!(sandbox.inputs.extra_write.is_empty());
5482        }
5483    }
5484
5485    /// A backend that cannot honor the resolved sandbox must never silently
5486    /// run bare: by default the resolution FAILS CLOSED naming the backend
5487    /// and the flag; with the opt-in the wrap is skipped and the note names
5488    /// the backend.
5489    #[test]
5490    fn validator_containment_off_non_claude_backend_fails_closed_unless_opted_in() {
5491        for backend in [
5492            crate::types::BackendKind::Codex,
5493            crate::types::BackendKind::Droid,
5494            crate::types::BackendKind::Kimi,
5495            crate::types::BackendKind::Local,
5496            crate::types::BackendKind::Acp,
5497            crate::types::BackendKind::Cursor,
5498        ] {
5499            let err = resolve_validator_containment_target(
5500                &off_cfg(),
5501                backend,
5502                Path::new("/snap"),
5503                Path::new("/mission"),
5504                &[PathBuf::from("/repo")],
5505                false,
5506                "macos",
5507                false,
5508                None,
5509                None,
5510            )
5511            .expect_err("an uncontainable backend fails closed by default");
5512            let err = err.to_string();
5513            assert!(err.contains(backend.as_str()), "{err}");
5514            assert!(err.contains("validatorAllowUncontainedDegrade"), "{err}");
5515
5516            let containment = resolve_validator_containment_target(
5517                &off_cfg(),
5518                backend,
5519                Path::new("/snap"),
5520                Path::new("/mission"),
5521                &[PathBuf::from("/repo")],
5522                true,
5523                "macos",
5524                false,
5525                None,
5526                None,
5527            )
5528            .expect("the opt-in restores the loud degrade");
5529            assert!(
5530                containment.sandbox.is_none(),
5531                "{backend:?} must not get a wrap it cannot honor"
5532            );
5533            let note = containment.note.expect("the loud note");
5534            assert!(note.contains(backend.as_str()), "{note}");
5535            assert!(note.contains("validator-mandatory-containment"), "{note}");
5536        }
5537    }
5538
5539    /// `enforce != off` keeps the role's own resolution AND gains the
5540    /// read-deny roots on the process tier; the operator's extraWrite stays
5541    /// (the mandatory no-widening rule is the off-case wrap's).
5542    #[test]
5543    fn validator_containment_enforced_role_resolves_and_attaches_roots() {
5544        let mut cfg = fs_cfg();
5545        cfg.extra_write = vec!["~/keep".to_string()];
5546        let roots = vec![PathBuf::from("/repo")];
5547        let containment = resolve_validator_containment_target(
5548            &cfg,
5549            crate::types::BackendKind::Claude,
5550            Path::new("/repo/.kranz/missions/m-x/runs/snap"),
5551            Path::new("/repo/.kranz/missions/m-x"),
5552            &roots,
5553            false,
5554            "macos",
5555            false,
5556            None,
5557            None,
5558        )
5559        .expect("fs on macos resolves");
5560        assert!(containment.note.is_none(), "{:?}", containment.note);
5561        let sandbox = containment.sandbox.expect("the role's wrap");
5562        assert_eq!(sandbox.backend, SandboxBackend::Seatbelt);
5563        assert_eq!(sandbox.inputs.validator_read_deny_roots, roots);
5564        assert!(
5565            !sandbox.inputs.extra_write.is_empty(),
5566            "an enforced role keeps its declared extraWrite"
5567        );
5568    }
5569
5570    /// `enforce != off` stays fail-closed on an unknown platform (the
5571    /// runner's resolve_sandbox_or_refuse posture, unchanged).
5572    #[test]
5573    fn validator_containment_enforced_role_still_fails_closed_where_unsupported() {
5574        let err = resolve_validator_containment_target(
5575            &fs_cfg(),
5576            crate::types::BackendKind::Claude,
5577            Path::new("/snap"),
5578            Path::new("/mission"),
5579            &[PathBuf::from("/repo")],
5580            false,
5581            "solaris",
5582            false,
5583            None,
5584            None,
5585        )
5586        .expect_err("enforcement requested but unhonorable must fail closed");
5587        assert!(err.to_string().contains("unsupported"), "{err}");
5588    }
5589
5590    /// The container provider keeps its own (stronger) containment: resolved
5591    /// untouched, no read-deny roots attached (the real tree is simply not
5592    /// mounted). Under `enforce: off` the provider is ignored — the
5593    /// mandatory wrap is the process tier.
5594    #[test]
5595    fn validator_containment_container_provider_posture() {
5596        let cfg = crate::types::SandboxConfig {
5597            enforce: crate::types::SandboxEnforce::Fs,
5598            provider: crate::types::SandboxProvider::Container,
5599            ..crate::types::SandboxConfig::default()
5600        };
5601        let containment = resolve_validator_containment_target(
5602            &cfg,
5603            crate::types::BackendKind::Claude,
5604            Path::new("/snap"),
5605            Path::new("/mission"),
5606            &[PathBuf::from("/repo")],
5607            false,
5608            "linux",
5609            false,
5610            Some(crate::sandbox_container::ContainerRuntime::Docker),
5611            None,
5612        )
5613        .expect("container resolves with a runtime");
5614        let sandbox = containment.sandbox.expect("the container wrap");
5615        assert_eq!(sandbox.backend, SandboxBackend::Container);
5616        assert!(
5617            sandbox.inputs.validator_read_deny_roots.is_empty(),
5618            "the container's mounts are the containment — no process-tier deny set"
5619        );
5620
5621        let mut off_container = off_cfg();
5622        off_container.provider = crate::types::SandboxProvider::Container;
5623        let containment = resolve_validator_containment_target(
5624            &off_container,
5625            crate::types::BackendKind::Claude,
5626            Path::new("/snap"),
5627            Path::new("/mission"),
5628            &[PathBuf::from("/repo")],
5629            false,
5630            "macos",
5631            false,
5632            None,
5633            None,
5634        )
5635        .expect("off+container still gets the mandatory process-tier wrap");
5636        assert_eq!(
5637            containment.sandbox.expect("a wrap applies").backend,
5638            SandboxBackend::Seatbelt,
5639            "provider:container with enforce:off documents 'no sandboxing'; the mandatory wrap is process-tier"
5640        );
5641    }
5642
5643    /// Applied proof on macOS (the ticket's test gate): a validator-session
5644    /// fixture under `enforce: off`-shape inputs provably CANNOT read the
5645    /// real checkout's source tree or the authority material, while the
5646    /// shared git dir and the snapshot stay readable.
5647    #[cfg(target_os = "macos")]
5648    #[test]
5649    fn validator_containment_macos_denies_real_checkout_reads() {
5650        use std::process::Command;
5651
5652        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
5653        if !sandbox_exec_can_apply() {
5654            return;
5655        }
5656        let (_dir, root, snapshot, mission) = validator_containment_fixture();
5657        let scratch = tempfile::tempdir().unwrap();
5658        let profile = generate_profile(&validator_containment_inputs(
5659            &root,
5660            &snapshot,
5661            &mission,
5662            scratch.path(),
5663        ));
5664        let profile_dir = tempfile::tempdir().unwrap();
5665        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
5666
5667        let read = |path: &Path| {
5668            Command::new("sandbox-exec")
5669                .arg("-f")
5670                .arg(&profile_path)
5671                .arg("/bin/cat")
5672                .arg(path)
5673                .status()
5674                .expect("failed to run sandbox-exec")
5675        };
5676        // The real checkout's source tree is unreadable…
5677        for denied in [
5678            root.join("src").join("secret.rs"),
5679            root.join("Cargo.toml"),
5680            root.join(".env"),
5681        ] {
5682            assert!(
5683                !read(&denied).success(),
5684                "read of the real tree must be denied: {}",
5685                denied.display()
5686            );
5687        }
5688        // …and so is the sensitive .kranz runtime the carve-out would
5689        // otherwise reopen (14th-pass review,
5690        // validator-containment-kranz-overread): the plaintext lint
5691        // vocabulary, the hook-status projection, and the control inbox.
5692        for denied in [
5693            root.join(".kranz").join("domain-terms.local"),
5694            root.join(".kranz")
5695                .join("hook-status")
5696                .join("m-x")
5697                .join("run-1.json"),
5698            mission.join("control").join("approve.json"),
5699        ] {
5700            assert!(
5701                !read(&denied).success(),
5702                "read of the sensitive .kranz runtime must be denied: {}",
5703                denied.display()
5704            );
5705        }
5706        // …the root LISTING stays visible (names, never contents — a
5707        // literal deny on the root breaks coreutils `mkdir -p`, which stats
5708        // every ancestor; documented on the entries helper)…
5709        let listing = Command::new("sandbox-exec")
5710            .arg("-f")
5711            .arg(&profile_path)
5712            .arg("/bin/ls")
5713            .arg(&root)
5714            .status()
5715            .expect("failed to run sandbox-exec");
5716        assert!(
5717            listing.success(),
5718            "the root listing stays open (names, never contents)"
5719        );
5720        // …and the authority material stays denied through the carve-out.
5721        assert!(
5722            !read(&root.join(".kranz").join("serve.token")).success(),
5723            "the authority read deny must survive the .kranz carve-out"
5724        );
5725        // The narrow legitimate surfaces stay readable: the shared git dir
5726        // and the validator's own snapshot worktree.
5727        for allowed in [root.join(".git").join("HEAD"), snapshot.join("README.md")] {
5728            assert!(
5729                read(&allowed).success(),
5730                "read must keep working: {}",
5731                allowed.display()
5732            );
5733        }
5734    }
5735
5736    /// The second applied half: writes outside the snapshot are denied
5737    /// (source tree, mission metadata, and the shared git refs — the
5738    /// tripwire's domain, now hard-denied), while the snapshot stays
5739    /// writable and read-only git (`log`/`status`/`diff` — the inspection's
5740    /// surface) keeps working: the validation round still completes.
5741    #[cfg(target_os = "macos")]
5742    #[test]
5743    fn validator_containment_macos_keeps_snapshot_writes_and_readonly_git() {
5744        use std::process::Command;
5745
5746        let _guard = SANDBOX_EXEC_TEST_LOCK.lock().unwrap();
5747        if !sandbox_exec_can_apply() {
5748            return;
5749        }
5750        let Some((_dir, root, snapshot, mission)) = validator_containment_git_fixture() else {
5751            return;
5752        };
5753        let scratch = tempfile::tempdir().unwrap();
5754        let profile = generate_profile(&validator_containment_inputs(
5755            &root,
5756            &snapshot,
5757            &mission,
5758            scratch.path(),
5759        ));
5760        let profile_dir = tempfile::tempdir().unwrap();
5761        let profile_path = write_profile_file(profile_dir.path(), &profile).unwrap();
5762        let sh = |command: &str| {
5763            Command::new("sandbox-exec")
5764                .arg("-f")
5765                .arg(&profile_path)
5766                .arg("/bin/sh")
5767                .arg("-c")
5768                .arg(command)
5769                .status()
5770                .expect("failed to run sandbox-exec")
5771        };
5772
5773        // Write denies: the real tree, the root, the engine's metadata, and
5774        // the shared git plumbing (index + refs).
5775        for command in [
5776            format!("echo x >> {}", root.join("tracked.rs").display()),
5777            format!("echo x > {}", root.join("new.txt").display()),
5778            format!("echo x >> {}", mission.join("events.jsonl").display()),
5779            format!("git -C {} add -A", snapshot.display()),
5780            format!("git -C {} branch -f side HEAD", snapshot.display()),
5781        ] {
5782            assert!(!sh(&command).success(), "must be denied: {command}");
5783        }
5784        // The snapshot stays fully writable (the warmed-target shape)…
5785        assert!(sh(&format!(
5786            "mkdir -p {0}/target && echo built > {0}/target/out && echo note > {0}/notes.txt",
5787            snapshot.display()
5788        ))
5789        .success());
5790        // …and the read-only git inspection surface works — the functional
5791        // and scrutiny validators' whole job in the snapshot.
5792        let git_log = Command::new("sandbox-exec")
5793            .arg("-f")
5794            .arg(&profile_path)
5795            .arg("git")
5796            .arg("-C")
5797            .arg(&snapshot)
5798            .arg("log")
5799            .arg("--oneline")
5800            .output()
5801            .expect("failed to run sandbox-exec");
5802        assert!(
5803            git_log.status.success(),
5804            "read-only git must work in the snapshot: {}",
5805            String::from_utf8_lossy(&git_log.stderr)
5806        );
5807        assert!(String::from_utf8_lossy(&git_log.stdout).contains("init"));
5808        assert!(sh(&format!("git -C {} status --porcelain", snapshot.display())).success());
5809        assert!(sh(&format!("git -C {} diff HEAD", snapshot.display())).success());
5810        // The snapshot's own copy of the source tree reads fine.
5811        assert!(sh(&format!("cat {}", snapshot.join("tracked.rs").display())).success());
5812    }
5813
5814    /// The linux applied analogue: bwrap masks the real tree (dirs ENOENT
5815    /// under the tmpfs shadow, files empty under /dev/null), keeps the
5816    /// carve-outs and the snapshot, and read-only git still works.
5817    #[cfg(target_os = "linux")]
5818    #[test]
5819    fn validator_containment_linux_bwrap_denies_real_checkout_and_keeps_snapshot() {
5820        use std::process::Command;
5821
5822        if !bwrap_can_apply() {
5823            return;
5824        }
5825        let Some((dir, root, snapshot, mission)) = validator_containment_git_fixture() else {
5826            return;
5827        };
5828        // An absent global authority directory makes its HOME the enclosing
5829        // private view. Restoring the repo under that view must not reopen
5830        // the validator's real-checkout read denies.
5831        let _env =
5832            crate::agent_env::EnvTestGuard::engage(&[("HOME", dir.path().to_str().unwrap())]);
5833        let scratch = tempfile::tempdir().unwrap();
5834        let inputs = validator_containment_inputs(&root, &snapshot, &mission, scratch.path());
5835        let run = |command: &str| {
5836            Command::new("bwrap")
5837                .args(
5838                    bubblewrap_args(
5839                        &inputs,
5840                        Path::new("/bin/sh"),
5841                        &["-c".to_string(), command.to_string()],
5842                    )
5843                    .unwrap(),
5844                )
5845                .output()
5846                .expect("failed to run bwrap")
5847        };
5848
5849        // A source DIR is shadowed: reads underneath fail outright.
5850        let shadowed = run(&format!(
5851            "cat {}",
5852            root.join("src").join("secret.rs").display()
5853        ));
5854        assert!(
5855            !shadowed.status.success(),
5856            "the tmpfs-shadowed source dir must not resolve: {}",
5857            String::from_utf8_lossy(&shadowed.stderr)
5858        );
5859        // A source FILE is /dev/null-masked: the open succeeds, the content
5860        // does not cross (the authority-mask idiom).
5861        let masked = run(&format!("cat {}", root.join("tracked.rs").display()));
5862        assert!(
5863            !String::from_utf8_lossy(&masked.stdout).contains("tracked"),
5864            "the masked source file must not yield its content"
5865        );
5866        // The authority material is masked too.
5867        let authority_read = run(&format!(
5868            "cat {}",
5869            root.join(".kranz").join("serve.token").display()
5870        ));
5871        assert!(
5872            !String::from_utf8_lossy(&authority_read.stdout).contains("secret-token"),
5873            "the authority material must stay masked"
5874        );
5875        // The carve-outs and the snapshot read fine.
5876        let git_head = run(&format!("cat {}", root.join(".git").join("HEAD").display()));
5877        assert!(git_head.status.success());
5878        let snap_read = run(&format!("cat {}", snapshot.join("tracked.rs").display()));
5879        assert!(
5880            String::from_utf8_lossy(&snap_read.stdout).contains("tracked"),
5881            "the snapshot's own copy reads fine"
5882        );
5883        // Writes outside the snapshot fail (the whole fs is ro-bound); the
5884        // snapshot and the git plumbing behave like the Seatbelt side.
5885        for command in [
5886            format!("echo x >> {}", root.join("tracked.rs").display()),
5887            format!("echo x >> {}", mission.join("events.jsonl").display()),
5888            format!("git -C {} branch -f side HEAD", snapshot.display()),
5889        ] {
5890            assert!(!run(&command).status.success(), "must be denied: {command}");
5891        }
5892        assert!(
5893            run(&format!("echo built > {}/target-out", snapshot.display()))
5894                .status
5895                .success()
5896        );
5897        let git_log = run(&format!("git -C {} log --oneline", snapshot.display()));
5898        assert!(
5899            git_log.status.success(),
5900            "read-only git must work in the snapshot: {}",
5901            String::from_utf8_lossy(&git_log.stderr)
5902        );
5903    }
5904}
5905
5906#[cfg(test)]
5907#[path = "git_config_protection_tests.rs"]
5908mod git_config_protection_tests;