Skip to main content

Module command_exec

Module command_exec 

Source
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 sibling sandbox-exec invocations with the identical profile stay EPERM. Probe evidence is recorded in [gate_profile_extras].
  • proc_pidinfo-first identity tokens (event_log.rs): /bin/ps is 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 reads p_starttime directly (ungated for same-uid pids, byte-identical rendering to ps -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/ps at all (the ps-fixture tests), and nested sandbox_apply of 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§

MergeGatePolicy
What the merge-gate path needs to wrap its gates (ticket engine-gates-sandbox-wrapped): the MERGED mission’s worker.sandbox config (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 inside run_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_blocking thread; 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_command under a MergeGatePolicy (ticket engine-gates-sandbox-wrapped). A non-enforcing policy delegates to run_bounded_gate_command unchanged — the byte-identical off path. An enforcing policy runs the gate inside the resolved profile with: