Files
deepseek-harness/docs/rfc/implemented/feature/2026-07-06-sandbox.md
2026-07-14 16:21:41 +08:00

36 KiB

RFC: The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes

Status: implemented

Problem

A coding agent needs this product path: bash subprocesses — and the hook commands that ride them — execute under a restricted file sandbox by default; if and only if the sandbox actually denies an operation, the model may request one user approval for that same operation and, once granted, retry it once with wider permissions. An every-tool boundary is deliberately NOT the claim: fs/web/todo execute in-process where an execve wrapper is meaningless (§ In-process tools), and the cross-family boundary is staged follow-up work (§ Deferred phases). Without a shared vocabulary, every tool reinvents approval fields, denial parsing, retry matching, and permission-state hints.

The harness is an SDK, so confinement must be a capability developers COMPOSE: whether to sandbox, and which backend per platform, belongs in the leaf cordis.yml as a first-class entry — not inside one executor's private machinery. And the first-choice runner, bwrap, is unusable on exactly the hosts a sandbox matters most (minimal containers, disabled unprivileged userns, LSMs that deny mount), so a fallback runner has to ship with the SDK rather than be assumed on the host.

Confinement alone leaves two gaps. A denial with no escalation path is terminal — the model can only give up, which pressure-cooks operators into configuring workspace-write or danger-full-access globally and defeats the sandbox. And the model-visible knobs (the sandbox mode, the approval policy) change over an agent's lifetime — an ACP user flips a per-session setting, an operator edits cordis.yml while the process is down — while the model must never act on a stale belief about them: what IS the state on every request, what changed while the agent lives, and what changed while nobody was watching all need answers.

Decision

One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf cordis.yml; nothing touches agent-loop. The scope is deliberately bounded: the phases this RFC names but does not design — per-session workspace root, cross-family fs enforcement, the subagent-acp consumer, more environments, a Windows chain — are listed under § Deferred phases, each a follow-up design, not a config knob.

How a deployment uses it

Four cordis.yml entries turn an unconfined coding agent into the sandboxed product path; examples/acp-agent uses this composition by default:

- id: sandbox
  name: '@deepseek-ai/dsh-sandbox-local'   # the per-platform runner provider (ctx.sandbox)
- id: bash
  name: '@deepseek-ai/dsh-bash-sandbox'    # the confined executor, replacing dsh-bash-local behind ctx.bash
  config:
    mode: workspace-write                  # the deployment default every session starts from
    workspaceRoot: !!js process.cwd()      # the boundary workspace-write may write under
- id: approval
  name: '@deepseek-ai/dsh-user-approval'        # the escalation gate's channel (the approval RFC)
  config:
    policy: ask
- id: permission
  name: '@deepseek-ai/dsh-permission'      # one product-facing select over both mechanism knobs

The swap is invisible to every consumer of ctx.bash: the bash tools, hook commands, and background tasks run exactly as before, spawned through the wrapped argv the provider returns. Deleting the sandbox and permission entries and replacing bash with @deepseek-ai/dsh-bash-local is the opt-out — execution is unconfined again and the escalation fields vanish from the tool schema, because they are capability-gated on the mounted executor, not on configuration. Omitting only approval keeps confinement but fails every escalation closed with its own error text; permission also requires the approval seam and a confining executor, so a partially composed preset layer fails loud at load.

Misconfiguration fails loud: mode outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured SANDBOX_UNAVAILABLE — at confine() before the command ever spawns — rather than degrading to unconfined execution. runnerCommand on dsh-sandbox-local is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests.

Denied file effects return a [sandbox: file access denied under <mode> mode] marker and instructions not to work around the denial. A confining executor adds paired sandbox_permissions and justification fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns the user rejected escalating this command to "<mode>", and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When dsh-permission is composed, ACP exposes one Permissions select whose presets write both knob events; unmatched knobs appear as switch-away-only custom. Only a switch to the deterministic 'never' approval policy is stated in the prompt and narrated.

Design detail

Scope grounding

OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over ctx. The existing bash request/spec split carries per-call overrides, while tools/pre-execute and the approval seam own the human decision.

The seam: ctx.sandbox

dsh-sandbox owns the vocabulary and the SandboxProvider contract: confine(argv, policy) returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the enforcement completeness the selected backend achieves, its denial dialect (denialSignatures, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (runnerFailureSignatures, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed SANDBOX_UNAVAILABLE error, never a silent unconfined passthrough. The vocabulary: SandboxMode (read-only / workspace-write / danger-full-access, FILE effects only — network and process visibility are not claimed), SandboxEnforcement (full / partial), SandboxPolicy (mode + workspace root).

Policy rides each CALL, not the provider: two consumers may confine under different policies at the same instant (bash under read-only while a confined child agent keeps its state directory writable), and an approved escalated retry is a new call with a wider policy — inexpressible under a config-fixed provider mode.

The seam confines SAME-WORLD subprocesses only: a backend shares the host's filesystem and kernel. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (ctx.bash, ctx.fs) as environment-coherent groups, because an agent whose bash runs in a container while its fs tools write the host lives in two split worlds.

Left open, for the phase that needs them: whether network restriction arrives as a separate network_mode or merges into sandbox_mode once a runner enforces both, and whether SandboxPolicy grows extra writable-root grants now (the launcher already speaks --rw <path>) or only when escalation needs them.

Local backends and the shipped launcher

dsh-sandbox-local selects one platform runner per provider lifetime and caches the verdict. Linux functionally probes bwrap then Landlock; macOS uses Seatbelt. Unsupported platforms and unusable runners fail closed. Each wrap carries backend-specific denial and runner-failure signatures so dsh-bash-sandbox can distinguish a denied file effect from a broken sandbox. runnerCommand skips selection as an operator assertion of a bwrap-shaped runner, but missing or unexecutable commands still classify as sandbox failure and never run the payload unconfined.

The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): --ro <path> / --rw <path> grants, --, the wrapped argv; it installs the ruleset on itself and execs (rulesets are inherited across execve, and it sets no_new_privs before restricting); --probe enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing.

The Landlock launcher ships through node-addon-landlock-run, with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.

FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together.

Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only /dev/null in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement.

The bash consumer

dsh-bash-sandbox reuses local process execution and asks ctx.sandbox to wrap the exact bash argv. A kernel denial is a result fact independent of exit status and is inferred only from the selected wrap's stderr dialect. Runner failure outranks denial because it means the command never ran: foreground calls throw SANDBOX_UNAVAILABLE, while settled background tasks set sandbox.runnerFailed for bash_output. This keeps broken confinement distinct from both task failure and an enforced denial.

The model's view is result facts only: the static tool description explains the denial marker ([sandbox: file access denied under <mode> mode]), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes).

Escalation: one approved wider retry after a denial

BashExecRequest.sandboxMode is an optional per-call input; resolved specs make the field explicit. BashExecutor.sandboxMode advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.

SandboxBashExecutor.resolve() stamps the effective mode — escalation grant > session override > configured default — so run()/start() read the spec, never the config. The danger-full-access branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (notifyTaskDone() stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own.

When a confining executor is mounted, bash advertises paired sandbox_permissions and justification fields. The schema exposes the full closed escalation vocabulary because effective mode is per-session; execution rejects any target that is not strictly wider than that call's effective mode. Approval resolves before execution. allowed-once stamps the granted mode onto only that request, while rejected, cancelled, unavailable, a missing approval service, or a missing agent all fail closed with distinct results. No grant is persisted.

Escalation is a same-turn retry of the denied command with the narrowest sufficient sandbox_permissions and a justification; the approval prompt is the consent step. It must be grounded in an actual denial, except when the session already observed the same denied access, and a disabled or rejected approval ends that command. The retry, approval decision, and result use existing tool and approval events. dsh-tool-bash owns the ask because the executor seam has neither the agent nor call id required for user interaction.

Left open, recorded for the phase that picks them up: what a grant's scope identity is beyond the sandbox mode — the exact call, a path, a command prefix, the session, a time window — the question allow_always grant storage must answer before that option can be advertised; and how escalation is defined for run_in_background denials that arrive via bash_output.

Per-session modes: the session log as the store

effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default

The default is composition config (cordis.yml) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's workspace-write cannot disturb another's read-only) both fall out by construction, and no external config store exists anywhere.

One event per knob, owned by its domain — the merge-extensible SessionEventMap idiom every existing event family already follows (approval/* in dsh-user-approval, hook/* in the hooks packages):

interface SessionEventMap {
  'bash/sandbox-mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' }
  'approval/policy': { policy: 'ask' | 'never' }
}

Each owner exports the same three-piece kit: the event declaration, a pure fold (effectiveSandboxMode(events) / effectiveApprovalPolicy(events) — a findLast, typed to the domain's closed union), and THE write path (setSandboxMode(session, mode) / setApprovalPolicy(session, policy) — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's 'never' gate is the approval RFC's side of the same pattern.

Sandbox mode is not narrated in the prompt; denial results report the mode when it matters, avoiding preemptive refusal based on a standing label. Approval policy is different: only 'never' is stated because automatic rejection otherwise looks like a user decision. Policy-change notices are coalesced and delivered by the next pre-step, with log-derived fallback after restart. The notice source is inferred from event position: a knob event after the last request header is user-driven; unlogged drift is operator or config driven.

The editor surface is protocol-native Session Config Options — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. When ctx.permission is composed, the bridge advertises one permission select (category mode) in session/new and session/load; its options are the deployment's preset table, and its currentValue is PermissionService.current() over the session log plus composition defaults. The shipped workspace-write and danger-full-access presets each bundle a sandbox mode with an approval policy and write through to both domain setters; a knob combination outside the table is reported as switch-away-only custom. session/set_config_option validates and switches through the permission service, then returns the complete refreshed state (the spec contract).

Turn enclosure is the commit boundary. A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Openness comes from log boundaries rather than agent.status, and setters do not append from inside a session/event listener because that would reorder later observers. Until anchoring, responses overlay the pending value. A crash discards it, and reload returns the durable fold.

In-process tools

fs/web/todo execute in-process, so their sandbox semantics are policy at their seams: the fs intent gates deciding by the shared mode vocabulary (§ Deferred phases, cross-family) make read-only a real boundary instead of a bash-only approximation — until then the contract says so honestly. No generic per-tool sandbox runtime: a host-mediated tool leaves the process only by returning declarative effects the host validates, which is a rewrite, not a wrapper.

FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam.

Testing

  • Unit: pin platform selection and profiles, fail-closed runner classification, per-call facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
  • Keyless real-runner: exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
  • With-key: drive a real model, runner, bridge answerer, and disk effect through granted and rejected escalation; unavailable credentials or runners self-skip.
  • Snapshot: pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. Snapshot mode starts unconfined so unrelated fixtures remain platform-independent; policy scenarios switch explicitly. Real denial stderr stays on platform tests because its dialect is runner-specific.

Deferred phases

Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches.

  • Per-session workspace root — the executor's write boundary stays config-fixed for its lifetime while each ACP session has its own cwd; a per-session root rides the same per-call policy carrier once designed.
  • Cross-family boundary — the fs intent gates decide by the shared mode, making read-only/workspace-write real boundaries beyond bash.
  • Second consumersubagent-acp optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence).
  • More environments — an environment-coherent capability group example (e.g. bash+fs against one container).
  • Windows chainPLATFORM_CHAINS.win32 is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the node-addon-landlock-run template, plus its profile dialect and denial/runner-failure signatures.

Alternatives considered

  • Command-string heuristic preflight — rejected: cannot understand expansion/subprocesses/symlinks; the strict attempt (run it, let the kernel decide) is the only trustworthy denial signal.
  • Functionally probe even a platform's sole backend — rejected: probing arbitrates between candidates; with one there is nothing to decide, and probe cost taxes the first confined command of every session (prohibitive for heavy future backends). The runner's own exec-time fail-closed refusal plus runnerFailureSignatures classification carries the safety property instead.
  • Commit the built launcher binaries — rejected: a binary in a diff is unreviewable and churns history; reviewed source + native CI builds + the launcher repo's byte-pinned publish rehearsal keep bytes out of every tree.
  • Compile the launcher on install — rejected: pushes a C toolchain onto every consumer; a fallback that exists only where a compiler happens to be is not a fallback.
  • Cross-compile both architectures from one builder — rejected: requires carrying a pinned cross toolchain (rustup targets, zig, or a container image) solely to rebuild two ~70 KB binaries; per-architecture native runners already exist and each builds its own platform package (the node-addon-require-builtin model, the launcher repo's own pipeline).
  • No fallback (bwrap or fail closed) — rejected: concentrates failure on the hosts a sandbox matters most, degrading to danger-full-access by resignation.
  • Keep the mechanism inside dsh-bash-sandbox — rejected: blocks the existing second consumer, makes future phases read mode out of a bash plugin's config, and cannot express escalation.
  • Config-fixed mode on the provider — rejected: one mode per process; cannot serve concurrent consumers with different policies nor the one-shot widened retry.
  • One interface spanning containers/VMs too — rejected: confine(argv) presupposes a shared filesystem; environment isolation is capability-sibling backends deployed as coherent groups.
  • Generic ToolRuntime wrapping any tool — rejected: mechanically false for in-process tools (closures over ctx); the declarative-effects rewrite is unjustified for fs/web/todo.
  • Ask inside the executor (dsh-bash-sandbox) — rejected: no agent to route through, no callId to attach the prompt to; adding them teaches a transport seam about sessions and UIs — the tool layer holds both and owns the model-facing vocabulary.
  • Auto-retry inside the same tool call — rejected: a hidden re-entry the log cannot reconstruct: one tool/call would have produced two executions with different policies — the retry is a NEW logged call with its own arguments and result facts.
  • Advertise the escalation fields unconditionally — rejected: under dsh-bash-local they are a dead lever — advertising an option the harness cannot honor manufactures doomed grants; capability-gating costs one registration-time read.
  • A default-relative escalation ladder (advertise only the modes wider than the executor's registration-time default) — rejected: per-session overrides make the default the wrong baseline — a session switched narrower than the default loses exactly the lever it needs, and under a danger-full-access default the fields vanish entirely while a read-only-overridden session stays confined with no escalation path. The enum pins the closed target vocabulary; strict widening is a per-call execution check against the session's effective mode.
  • Per-session dynamic tool schemas — rejected: schemas are registry-global by design (one assembly vocabulary, the pinned-header snapshot contract), and re-registering per session would buy only what the execution-time strict-wider check already guarantees, at the cost of a per-session schema surface and header churn on every switch.
  • Hard-match the retry to a prior denial — rejected: command-string identity is fragile (quoting, workdir, env prefixes, a pipeline retried as its failing stage) — false-rejects honest retries or is trivially satisfied; the real boundary is the human seeing command + justification. Revisit only if allow_always grant storage ever needs machine-checkable scopes.
  • A generic env/state facts map with an owner service — rejected: approval and sandbox compose independently, so neither's state may drag in a third package; single-key folds are one findLast each, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing.
  • Narrate via agent/user-message + a bus event — rejected: it presupposes a turn-entry seam that does not exist (the real seam is agent/prompt-submit), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener.
  • A standing prompt statement of the sandbox mode (+ a switch narrator) — shipped first, then removed on live evidence: with Bash commands run under the "read-only" file sandbox. in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no".
  • Track "last told" with its own bookkeeping events — rejected: the request/header* fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store.
  • ACP session modes instead of config options — rejected: the preset is already one deployment-defined config-option select, and modes are slated for removal in ACP v2.

Consequences

What shipped pins — the tiers in Testing hold each:

  • A denied command retried with sandbox_permissions + justification prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing.
  • The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
  • The system prompt never states the sandbox mode (an approval 'never' policy is the one stated knob), and the whole exchange — headers, knob events, notices, approvals, results — reconstructs from the session log alone, with no event types beyond the two knob events.
  • N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp.
  • A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator.
  • Two concurrent sessions never see each other's state, notices, or config options.
  • agent-loop is untouched — everything rides systemPrompt.section, SessionEventMap merging, agent.inject(), agent/pre-step, agent/prompt-submit, and the ACP handler surface.

Costs and accepted limits:

  • The one-wrapper illusion is given up knowingly. A tools/pre-execute wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it.
  • read-only is not yet a cross-family boundary. Until the fs intent gates decide by the shared mode, the claim holds for bash only; the contract says so honestly (§ In-process tools).
  • Windows has no backend. Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase.
  • The Seatbelt rung leans on Apple's deprecated-but-shipped sandbox-exec CLI. As darwin's sole candidate it is selected without probing, so a future removal surfaces at execution as the runner-failure classification — re-thrown SANDBOX_UNAVAILABLE, the command never runs; fail closed, never open.
  • Landlock confinement is only as complete as the running kernel's ABI. Reported as enforcement: 'partial' rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts.
  • The launcher arrives as a registry dependency. Trusted through its own repository's release pipeline (reviewed C source, native CI builders, byte-pinned publish rehearsal) plus this repo's version pin — the real-kernel e2e legs are what vouch for behavior through the installed bytes.
  • The model may over-ask. Escalating without denial grounding, or picking danger-full-access where workspace-write suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the approval/asked reasons make over-asking auditable, and a prepend policy answerer can auto-reject patterns a deployment never wants.
  • The advertised target set is static while the effective mode is per-session (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
  • A granted escalation is not a working sandbox. An unavailable backend still fails closed even for a granted escalation to a confining mode — at confine() when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted danger-full-access run never touches the provider at all: there the grant, not the probe, is the authority.
  • An idle switch lives in bridge memory until the next prompt submission anchors it. A crash in that window reverts it (reported on session/load), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required.
  • The approval narrator's restart baseline parses prompt prose. The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice.
  • The approval section is still a dynamic prompt surface (a 'never' switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale 'never' is worse. The sandbox knob no longer touches the prompt at all.
  • The model may hold a stale belief about the sandbox mode (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry.

FAQ

  • A command came back with [sandbox: file access denied under read-only mode] — did it fail? It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request.
  • How is a BROKEN sandbox told apart from a failing command? Runner failure outranks denial in classification: a failed run matching the wrap's runnerFailureSignatures means the command NEVER ran — foreground re-throws the structured SANDBOX_UNAVAILABLE with the runner's stderr line, a background task stamps sandbox.runnerFailed and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined.
  • What happens on a platform with no backend — Windows today? confine() throws the fail-closed SANDBOX_UNAVAILABLE and the command never spawns; win32 is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases).
  • bwrap is installed on my host but unusable (disabled unprivileged userns, an LSM denying mount) — what happens? The chain probe is functional — it builds and enforces a real profile rather than checking --version — so a present-but-unusable bwrap fails its probe, selection falls to the registry-installed Landlock launcher, and the verdict is cached for the provider's lifetime.
  • Does the sandbox restrict network or process visibility? No — SandboxMode claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
  • Which tools actually run confined? OS subprocesses through ctx.bash — the bash tools, and hook commands transitively. fs/web/todo execute in-process, where an execve wrapper is mechanically meaningless; their read-only semantics arrive with the cross-family deferred phase, and until then the contract says bash-only honestly.
  • Does a granted escalation persist, or cover background tasks? Neither: the grant is consumed by the very call that asked (foreground or background), that one call reports the mode it actually ran under, and every neighbor keeps its own. How escalation should be DEFINED for a background denial that only surfaces later via bash_output is left open in § Escalation.
  • When does an editor's mode switch take effect? Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next agent/prompt-submit inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and session/load reports the truth. The model is not told — its next command simply behaves under the new mode.
  • What survives a restart — and what if the operator changed the config default while the process was down? Overrides replay from the session log (effective = fold ?? config), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution).
  • What does enforcement: 'partial' on a result mean? The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report full.

Prior art

In-repo precedents this design copies or contrasts with:

  • The capability-seams RFC — the interface/implementation/consumer split and the "don't split preemptively" timing rule the second consumer satisfied.
  • The dsh-bash request/spec split and its owner field (the bash vocabulary catalog) — the per-call carrier template sandboxMode rides, and the explicit-resolve() defaulting convention.
  • The approval seam RFC — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there.
  • Event-sourced sessions and the turn-enclosure invariant — the log-as-store foundation the per-session modes fold over, and the commit boundary the anchoring design obeys.
  • The interception-seams RFC — the tools/pre-execute vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own).