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