Files
deepseek-harness/docs/rfc/implemented/feature/2026-07-06-sandbox.md
T
2026-07-12 03:36:43 +08:00

43 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

Three cordis.yml entries turn an unconfined coding agent into the sandboxed product path; examples/sandbox-acp-agent is this composition, live:

- 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: read-only                        # 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)

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 bash entries and loading @deepseek-ai/dsh-bash-local instead 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.

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.

What the model then experiences: denied file effects come back as result facts with a [sandbox: file access denied under <mode> mode] marker plus standing instructions not to retry around them; under a confining executor the schema offers sandbox_permissions + justification for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: a sandbox-mode and an approval-policy config-option select per session (each advertised only when its knob is composable), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to 'never' is stated in the prompt and narrated.

The product path, concretely (the escalation arc is verbatim from the recorded escalation-approved scenario; the denial leg is pinned on the real-kernel e2e tier):

tool/result   … [sandbox: file access denied under read-only mode]   ← the write RAN; the kernel refused it
tool/call     bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
                    "sandbox_permissions": "workspace-write",
                    "justification": "the user asked to write escalated.txt in the workspace"}
  → the editor is prompted on this very call (session/request_permission through the approval seam); Allow once
tool/result   "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only

Reject instead and nothing executes: the result is the verbatim the user rejected escalating this command to "workspace-write", and the teaching makes that final — no re-ask.

Design detail

Grounding — verified against the code

  • Runtime OS subprocesses exist at exactly two sites: the ctx.bash seam's single spawn (packages/bash/bash-local/src/run.ts; hook commands flow through ctx.bash, so bash confinement covers them transitively) and subagent-acp's child agents (packages/subagent/subagent-acp/src/run.ts) — the second consumer that makes a shared seam due rather than preemptive under the capability seams RFC's "don't split preemptively" rule.
  • Everything else executes inside the harness process (fs is in-process node:fs, web is in-process fetch, every ToolDefinition.execute() closes over ctx): an OS sandbox wraps execve and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change.
  • tools/pre-execute (allow/deny/ask) exists, with ask serviced by the approval seam; the fs intent gates are version guards with no mode input yet.
  • dsh-bash's request/spec split (BashExecRequestresolve()BashExecSpec) carries per-call fields the way escalation needs — owner is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak SandboxMode, so a per-call policy field adds no dependency edge.
  • The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the sandbox example suite's mode-switching fixture.

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 launcher lives in its own repository and reaches the harness as the npm package family node-addon-landlock-run (the per-platform-package pattern of node-addon-require-builtin and esbuild): an entry package — dsh-sandbox-local's one runtime dependency — plus per-platform binary packages selected at install time by npm's os/cpu fields. The entry package owns the launcher's CLI contract end to end (launcherPath() resolution with a never-existing fallback, the functional probe(), grantArgs() flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, landlockProfileArgs() mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it.

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.

Profile parity is honest rather than identical: under Landlock, read-only grants --ro / plus --rw /dev/null (the node, not /dev — the host's /dev/shm is a persistent shared tmpfs), and workspace-write grants the HOST /tmp where bwrap's is ephemeral; under Seatbelt, read-only likewise grants only the /dev/null literal, and workspace-write grants the host /tmp plus the per-user darwin temp dir (os.tmpdir() — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (denialSignatures: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured enforcement: 'full' | 'partial' fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report full.

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

The seam level is mechanism only. BashExecRequest carries sandboxMode?: SandboxMode, an explicit per-call policy input; BashExecSpec carries it required-but-nullable (the owner pattern: a forgotten field is a visible undefined, and resolve() is the one explicit defaulting step); BashExecutor exposes the capability fact get sandboxMode(): SandboxMode | undefinedundefined in the base class, the configured mode in SandboxBashExecutor — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (dsh-bash-local) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have.

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; how cancellation behaves while an approval prompt is pending; 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. The bridge advertises one independent select per composable knob — sandbox-mode (category mode) iff the mounted executor confines, approval-policy iff the approval seam is composed — with currentValue folded from each session's own log, in session/new and session/load responses. session/set_config_option validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract).

Anchoring: turn-enclosure is the commit boundary. The turn-enclosure contract makes a bare between-turns append invalid (the JSONL backend treats a post-turn/end tail as crash garbage; dev invariants throw). A switch while a turn is open appends immediately — openness read from the LOG (last boundary event is turn/start), not agent.status, which stays running between queued turns. An idle switch is held on the bridge's session record and anchored at the next turn's agent/prompt-submit — inside the turn, before anything in it assembles or executes, last write per knob, and OUTSIDE any session/event emit (appending from inside that feed reorders events for later-registered listeners — a bug the dev invariants caught live). Until anchored, the switch exists only in bridge memory: responses overlay it truthfully, and a crash before the next turn reverts it — session/load then reports the fold's truth, so the editor UI self-corrects rather than lies.

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 tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/sandbox-exec CLI contracts via fake runner scripts in dsh-sandbox-local; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background runnerFailed fact) against a fake provider in dsh-bash-sandbox; the error's structured identity in dsh-sandbox. The escalation matrix spans the three bash packages: verbatim carry-through in dsh-bash-local, stamp/branch/per-task-facts in dsh-bash-sandbox, and the capability gate, justification pairing, fail-closed texts (pinned verbatim), and grant stamping in dsh-tool-bash. The switching surface pins the folds, the stamping precedence, the 'never' gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and session/load reporting over a real two-process JSONL round trip.
  • Keyless real-runner e2e, split along the seam and per rung: CI's sandbox-e2e matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in dsh-sandbox-local (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and dsh-bash-sandbox (the through-ctx.bash consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (packed-install.e2e.ts): pnpm pack, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain node confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (examples/sandbox-acp-agent): the real cordis.yml tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values.
  • With-key e2e (examples/sandbox-acp-agent/tests/escalation.e2e.ts): real model + real runner + the REAL bridge answerer, world-verified — denied under read-only, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without DEEPSEEK_API_KEY or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI).
  • Snapshot tier (examples/sandbox-acp-agent/tests/acp.snapshot.ts): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the request/header-deltas the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted permissionAnswers (grant runs confined under workspace-write; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above.

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: one mode list cannot carry two orthogonal knobs; config options are the spec's designed surface 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 turn anchors it. A crash in that window reverts it (reported honestly on session/load), and a session that never runs another turn never persists it — accepted, with the loop-owned idle commit turn named as future work if durability becomes a requirement.
  • 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

Behavioral and usage questions only — every "why not X?" design question lives in Alternatives considered, whose job is exactly that.

  • 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 turn's agent/prompt-submit, 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).