Expand description
Bounded, process-tree-killed shell execution for contract and merge-gate
commands — extracted from orchestrator.rs in the monolith split (pure
code motion, no behavior change). These are the ONLY places the engine
runs user-authored shell: contract commands need real shell semantics
(sh -c / cmd /C), so argument handling, timeout kill discipline
(process group on unix, Job Object on Windows), output tailing, and
environment sanitization live here as one unit.
§Sandbox wrap (ticket engine-gates-sandbox-wrapped)
Env-clearing alone is not isolation: engine-run gates execute
worker-authored build scripts and test binaries, and an env-cleared
process still holds the engine’s filesystem and network authority (the
operator home is discoverable without HOME via pwent / /Users/*). When
the mission’s worker.sandbox.enforce is not off, the gate’s sh -c is
therefore wrapped in the SAME resolved profile an agent session would get
— [GateSandbox::Seatbelt] (sandbox-exec -f) on macOS,
[GateSandbox::Bubblewrap] on Linux, and [GateSandbox::AppContainer]
on Windows — reusing crate::sandbox’s
writable-root computation, mission-metadata write denies, and authority
read denies. enforce == off (and the documented no-op postures below)
keeps the pre-wrap behavior byte-for-byte.
The gate profile’s writable shape is the gate’s cwd (the worktree —
target/ and everything else a build writes lives under it) plus a
private scratch (validation/final gate: the mission’s runs/contract-home
the contract env already points HOME/TMPDIR/CARGO_HOME at; merge gate: a
per-run self-cleaning kranz-gate-* temp root). The gate profile also
appends one narrow extra the session profile lacks (see
[gate_profile_extras] for the evidence): a /dev/null write allow
(deny default otherwise rejects the redirects real gate scripts use
liberally — this repo’s gascity merge-gate scripts alone carry 148 of
them). SBPL allows compose order-independently and denies still take
precedence, so the append cannot weaken the generated profile; the
agent-session profile itself is deliberately untouched.
macOS xcrun posture (13th-pass review, P1 — prewarm + deny): the profile
used to append a name-anchored xcrun_db* write regex over the Darwin
per-user temp dir, because the xcrun shims behind /usr/bin/git et al.
refresh their tool-resolution cache there via confstr, IGNORING TMPDIR,
and a refresh under parallel spawns killed a wrapped cargo test with
EPERM. But that regex also let a wrapped gate WRITE the shared per-user
xcrun database — including the operator’s existing one, a mutation
surface outside the mission that later developer-tool invocations rely
on. The regex is GONE: prewarm_xcrun_cache_outside_sandbox refreshes
the cache OUTSIDE the sandbox once per resolve (cheap, bounded,
failure-tolerant), and a shim refresh that still races stale inside the
sandbox now fails loudly with the shim’s own EPERM — a documented edge,
never a silent hole. bwrap has no equivalent gap (--dev /dev covers
device writes; Linux has no xcrun shim).
Network posture: the profile’s, mirroring sessions — fs keeps full
egress (write containment is the fs-tier promise), fs+net cuts outbound
TCP to loopback. Sessions escape loopback through the filtering egress
proxy (crate::egress_proxy); engine-run gates are NOT wired through it —
it is session infrastructure, and standalone merge gates run with no
engine alive to host one — so an fs+net gate is offline-by-cache: the
stage-1 seeded cache-only Cargo home is its registry, and
CARGO_NET_OFFLINE=true is injected so a missing crate fails with a
clear cargo error instead of a kernel-denied socket. Toolchains without a
warm seeded cache (a cold npm ci) need enforce: fs.
§Container arm (ticket container-gate-wrapper)
With provider = "container" and enforce != off, the gate command runs
INSIDE the mission container instead of on the host beside the
container-wrapped sessions: [GateSandbox::Container] builds a
container_gate_run_args argv (the same run --rm -i --read-only shape
agent sessions get — gate cwd rw, mission metadata ro, scratch rw,
authority files /dev/null-masked) and executes it through the same
bounded core, so timeout/tree-kill/drain discipline is identical. The
deltas from the session argv: the payload is sh -c <command>, the
container is NAMED so the timeout path can force-remove it (the bounded
core’s group SIGKILL reaches the runtime client, not the in-container
tree — the daemon owns those processes), the gate’s sanitized env crosses
via -e flags (a runtime client forwards no env), and the real Cargo
root is NEVER mounted (a credential directory; the gate’s cache-only
CARGO_HOME under the rw scratch is forwarded instead — only the
credential-free <cargo>/bin shim dir crosses, alongside the read-only
rustup toolchain + npm cache the gate’s toolchain resolution needs).
fs+net mirrors the session container’s handling: empty egress →
--network none (the hard boundary); a non-empty list FAILS CLOSED at
resolve (no egress proxy exists engine-side, and the bridge would be
advisory-only — config::validate already refuses the pair up front).
A requested container with no runtime on PATH FAILS CLOSED at resolve,
mirroring session resolution (runner::resolve_sandbox_or_refuse) —
never a silent host-side gate under an enforced container config.
Measured spawn cost (2026-08-03, macOS 15, M-series, Seatbelt; harness:
gate_sandbox_wrap_measure). Per-spawn micro (true, 50 reps): 23.5ms
unwrapped vs 26.0ms wrapped — +2.5ms/spawn (+10.8%; across four runs the
absolute delta held at ~1.3–5.7ms). Real gate
(cargo test -p kranz-engine --lib with the sandbox-hostile skips named
in the harness, 2 reps, fresh cache-only Cargo home each rep): 97.7s
unwrapped vs 96.2s wrapped mean — a −1.5% delta, i.e. NO measurable
overhead at gate scale (noise; the ~2.5ms wrap cost vanishes against a
~97s gate). Nowhere near the ticket’s ~20% opt-in threshold, so the wrap
is the DEFAULT under enforce != off, not an opt-in.
§Gate supervision policy (ticket gate-sandbox-supervision-dogfood)
The wrap’s initial posture was session-parity for process supervision:
(allow signal (target self)), no ps. kranz’s OWN engine suite
legitimately spawns and supervises children (the sandbox/kill machinery
testing itself), so cargo test --workspace as a wrapped contract
command failed 11 self-referential tests (probed 2026-08-03) — a kranz
mission with process enforcement could not satisfy this repo’s mandatory
gate. The fix is a gate-SPECIFIC policy, never a global widening (the
session profile generator is untouched; everything rides the
[gate_profile_extras] append seam):
(allow signal (target same-sandbox)): the wrapped gate may signal (kill /kill(pid, 0)/ killpg) processes carrying its OWN sandbox label instance — precisely its descendant tree, hereditary across fork/exec — while launchd, unrelated same-uid host processes, and even siblingsandbox-execinvocations with the identical profile stay EPERM. Probe evidence is recorded in [gate_profile_extras].proc_pidinfo-first identity tokens (event_log.rs):/bin/psis setuid root, and setuid exec is kernel-denied inside ANY sandbox (probed 2026-08-05 — EPERM even under(allow default); not SBPL-expressible). The token path now readsp_starttimedirectly (ungated for same-uid pids, byte-identical rendering tops -o lstart=), so lock-liveness probes work inside the wrap; the setuid ps spawn remains as the fallback for other-uid pids (pid 1).- What NO policy can grant inside the wrap, so those suite tests skip
with the detectable
SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)marker instead: executing/bin/psat all (the ps-fixture tests), and nestedsandbox_applyof any profile but the identical one (the preflight/sandbox-enforcement tests — kernel-denied regardless of SBPL content).
The proving ground is a fixture, not a one-off:
gate_sandbox_wrap_dogfood_supervision_workspace_suite (ignored; run by
the rust-macos-wrapped-suite CI job) executes cargo test --workspace
through the real wrap and asserts a green exit, reporting the
skip-under-wrap marker count.
Structs§
- Merge
Gate Policy - What the merge-gate path needs to wrap its gates (ticket
engine-gates-sandbox-wrapped): the MERGED mission’s
worker.sandboxconfig (the gates execute that mission’s worker-authored test/build code, so the worker role’s posture is the right one — the same choice the sandbox preflight makes for contract-command probes) and the mission dir the metadata write-denies / authority read-denies derive from. The server builds one per merge from the folded event state; the gate cwd is only known per command, so the profile resolution itself happens per command insiderun_bounded_gate_command_sandboxed.
Functions§
- run_
bounded_ gate_ command - Execute one repository-owned merge gate with the same process-tree timeout
used by validation-contract commands, but with a deliberately small
inherited environment. This synchronous wrapper is intended for a
spawn_blockingthread; it owns a current-thread runtime so the robust async timeout/kill implementation remains the single source of truth. - run_
bounded_ gate_ command_ sandboxed run_bounded_gate_commandunder aMergeGatePolicy(ticket engine-gates-sandbox-wrapped). A non-enforcing policy delegates torun_bounded_gate_commandunchanged — the byte-identical off path. An enforcing policy runs the gate inside the resolved profile with: