Agent Note: The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes
Status: implemented
English | 中文
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. The sandbox mode and approval policy can also change over an agent's lifetime through deployment config or an optional UI policy control; execution and model-visible policy must derive from the same logged state.
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. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the subagent-acp consumer, more environments, and a Windows chain — stay under § Deferred phases.
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.shell
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 Agent Note)
config:
policy: ask
- id: permission
name: '@deepseek-ai/dsh-permission-presets' # one product-facing select over both mechanism knobsThe swap is invisible to every consumer of ctx.shell: the bash tools, hook commands, and background jobs run exactly as before by directly spawning 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() rather than degrading to unconfined execution. If the selected runner rejects with attributable ENOENT or EACCES, the consumer reports the same infrastructure error from the spawn channel before any command starts; other spawn errors retain local command-start semantics while still running nothing. 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 hook 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 owner-derived pending policy context states the current file policy without replacing those enforcement boundaries. When dsh-permission-presets is composed with a UI adapter, one preset selects both knob values; unmatched values fold to custom. The ACP automation composition does not mount that UI service and selects its deployment mode explicitly.
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 one-shot policy decision.
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 structured runner-failure evidence (runnerFailureRules, optional allowed exit codes plus fatal per-line signatures after exact informational-line exclusions); 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), SandboxExecutionPolicy (the complete per-capability-call mode + workspace root), and SandboxPolicy (the confined provider subset).
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 the Service Providers for whole capability seams (ctx.shell, 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.
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 signatures and runner-failure rules so dsh-bash-sandbox can distinguish a denied file effect from a broken sandbox. Landlock runner failure requires exit 125 plus a non-notice landlock-run: line; the exact partial-enforcement line is informational even when a child exits 1, 2, or 125. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. runnerCommand keeps its operator-facing runnerFailureSignatures config, requires non-empty single-line entries, and maps them into one internal fatal rule. The consumer directly spawns every returned argv, so a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable fails through the attributable ENOENT/EACCES spawn channel while a successfully launched child exit 126 or 127 remains ordinary. An operator-configured script necessarily owns its interpreter startup before it applies its profile.
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; every launcher failure exits 125 without running the child and prints a fatal landlock-run: line. A successfully exec'd child may also return 125, so status alone is not launcher evidence. An older ABI prints the exact landlock-run: partial enforcement (older Landlock ABI) notice before it executes the child, so that line is not fatal evidence.
The Landlock launcher source and package family live at native/landlock-run, next to the harness consumers and inside the root pnpm workspace. The in-repository Landlock release decision owns the shared lockfile, native build, pack rehearsal, and npm publication boundary. Platform binaries are selected by npm, and the entry package owns path resolution, probing, CLI flags, the fatal prefix, and the partial-enforcement notice while the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned.
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.
dsh-bash-sandbox extends LocalBashExecutor, hands ctx.sandbox the exact ['bash', '-c', command] argv, and directly spawns the provider result. This leaves shell semantics and BASH_ENV on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. A pre-process rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports ENOENT or EACCES with either an error.path equal to provider argv[0] or, when error.path is absent, an exact syscall: 'spawn <runner>'; a present path also requires syscall: 'spawn' or the exact spawn <runner>. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain local command-start semantics. Foreground execution converts runner failures to SANDBOX_UNAVAILABLE with the original detail; an asynchronous background rejection stamps runnerFailed: true, denied: false. A SubprocessRuntime that synchronously throws the same runner-identifying shape makes background start throw SANDBOX_UNAVAILABLE, while other synchronous errors propagate unchanged. After a process starts, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws SANDBOX_UNAVAILABLE with that fatal line as detail; a settled ShellProcess stamps sandbox.runnerFailed, and the bash producer renders it through generic job_output.
The model sees the current effective file policy in the owner-derived sandbox:policy context, while 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). The current-policy decision owns the context's rationale and boundaries.
ShellExecRequest.sandboxPolicy is an optional complete per-call input; resolved specs make the field explicit. ShellExecutor.sandboxMode remains the capability fact advertising whether the mounted executor can honor that policy, so only a confining composition exposes escalation. The seam accepts any explicit policy; the tool owns session resolution and the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined.
ctx.sandboxPolicy.resolve() stamps the complete execution policy — explicit escalation mode > session override > configured default, with SessionHeader.cwd > configured fallback root — before the executor runs. SandboxBashExecutor.resolve() retains that policy on the spec, or supplies the deployment fallback for a direct agentless caller, so run()/start() never read mutable session state. Per-process wrap facts are keyed by the returned ShellProcess; onProcessDone() receives spawn failure out of band from stderr classification and stamps that handle before done resolves, so overlapping processes retain their own modes and runner dialects.
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 Service Definition has neither the agent nor call id required for user interaction.
Left open: what a durable grant's scope identity is beyond the sandbox mode — exact call, path, command prefix, session, or time window — before an allow_always option can be advertised.
effective(session) = findLast(the session's 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 log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default (decision).
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 {
'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 Agent Note's side of the same pattern.
Before each proposed step, sandbox and approval policy are rendered as ordered contributions to one desired policy-context message. The listener reconciles that message against session history, the claimed batch, and its pending next-step inbox entry. Once claimed and entered, the loop records the complete sourced user/message; both 'ask' and 'never' are explicit, so neither owner needs switch narration or last-told state.
The optional UI surface is PermissionPresetService: a deployment-defined preset table whose entries bundle one sandbox mode with one approval policy. The shipped workspace-write and danger-full-access presets write through to both domain setters; a knob combination outside the table is reported as custom. UI adapters may expose that table as a selector. The automation-only ACP transport advertises no configuration selector and mounts no permission-preset service.
The committed event is the commit boundary. A runtime switch records its preset and changed knob events on the target session, and every later capability resolution folds the last values. Adapters own choosing a valid session append boundary; the ACP transport has no runtime switch path. (The former ACP idle-switch anchoring — holding a pending idle selection until the next prompt submission — left with that bridge.)
fs/web/todo execute in-process, so their sandbox semantics are policy at their capability boundaries. The fs seam now enforces the shared mode vocabulary through a sandboxed provider (dsh-fs-sandbox fences write/edit by mode; see the cross-family fs sandbox RFC), so read-only/workspace-write are real boundaries for the filesystem tools, not a bash-only approximation. web/todo remain unfenced (web's only effect is network, outside the file-effect mode vocabulary). 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 — the follow-up settled on one shared policy home (ctx.sandboxPolicy) with per-capability enforcement, not a uniform wrapper.
- Unit: pin platform selection and profiles, direct provider-argv handoff, spawn-level failures with invalid-workdir controls, missing/non-executable/missing-interpreter evidence, malformed-runner negative controls, confined
BASH_ENVordering, structured runner classification (including partial-Landlock notice-only child outcomes, gated fatal evidence, child exits 126/127, and foreground/background parity), per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization. - Keyless real-runner: exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage installs the current checkout's native tarballs and proves the launcher remains executable and byte-identical. CI rejects a silent all-skip.
- With-key: start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
- Snapshot: pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at
/tmp, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. A POSIX fake partial-Landlock provider pins direct bashfalseas an ordinary child result and a missing provider executable as foreground/background infrastructure failure through the assembled app. Other snapshots start unconfined so unrelated fixtures remain platform-independent.
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.
- Second consumer —
subagent-acpoptionally 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 chain —
PLATFORM_CHAINS.win32is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository undernative/following the@deepseek-ai/node-addon-landlock-runtemplate, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was considered and rejected — not battle-tested enough for a security invariant.
- 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 structured
runnerFailureRulesclassification 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 main repository'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-builtinmodel, retained by the main repository's native pipeline). - No fallback (bwrap or fail closed) — rejected: concentrates failure on the hosts a sandbox matters most, degrading to
danger-full-accessby 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: noagentto route through, nocallIdto attach the prompt to; adding them teaches a transport boundary 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/callwould 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-localthey 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-accessdefault the fields vanish entirely while aread-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 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 ifallow_alwaysgrant storage ever needs machine-checkable scopes. - A generic
env/statefacts 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 onefindLasteach, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing. - Narrate each switch through
agent.inject()plus a bus event — rejected: independent notices expose owner ordering and intermediate combinations, while one pre-step composition can enqueue the complete current state atomically. - State sandbox mode in the stable system prompt — 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 absence decision is superseded by the current-policy decision; this measurement and causal observation remain the evidence that any replacement must counter-test. - Track "last told" with its own bookkeeping events — rejected: session history records the exact policy context the model saw, while the claimed batch and pending inbox entry show what is entering or queued. Recomputing the desired message replaces a second bookkeeping stream — events are needed only where they ARE the store.
- Independent sandbox and approval selectors — rejected: one deployment-defined permission preset keeps the two policy knobs coherent for UI clients that expose runtime switching.
What shipped pins — the tiers in Testing hold each:
- A denied command retried with
sandbox_permissions+justificationprompts 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.
- One sourced policy-context message states the complete current sandbox and approval policies atomically; the whole exchange — context messages, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events.
- One preset selection records only changed knob values, while a no-op selection records nothing; the next pre-step upserts both current values atomically, and a committed sandbox switch is honored by the next call's stamp.
- A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message.
- Two concurrent sessions never see each other's state or notices.
- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd.
- Policy ownership stays in plugins through
SessionEventMapmerging, inbox mutation fromagent/pre-step, and capability-owned resolution; the generic loop only claims and records the final entered batch.
Costs and accepted limits:
- The one-wrapper illusion is given up knowingly. A
tools/pre-executewrapper 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-onlybecame a cross-family boundary through a follow-up. This RFC shipped bash-only enforcement; the cross-family fs sandbox RFC extends the same mode vocabulary to the filesystem tools through a sandboxedctx.fsprovider and relocates the mode/root config and thesandbox/modeoverride toctx.sandboxPolicy(§ In-process tools).- Windows is a partial backend. This RFC originally reserved an empty, fail-closed win32 chain; the later Windows ACL sandbox decision filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as
enforcement: 'partial', never promoted to the full promise. - The Seatbelt rung leans on Apple's deprecated-but-shipped
sandbox-execCLI. As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both becomeSANDBOX_UNAVAILABLE, and 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. - Runner attribution uses an in-band protocol. Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined.
- The launcher is a workspace dependency in source and an npm dependency after publication. The main repository tests reviewed C source, native CI builds, and byte-pinned local tarballs together before publishing the same package family; the real-kernel e2e legs vouch for behavior through those installed bytes.
- The model may over-ask. Escalating without denial grounding, or picking
danger-full-accesswhereworkspace-writesuffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; theapproval/askedreasons make over-asking auditable, and aprependpolicy 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, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granteddanger-full-accessrun never touches the provider at all: there the grant, not the probe, is the authority. - Runtime-context history is append-only. A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message.
- Older policy snapshots remain in history. Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message.
- 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? Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable
ENOENTorEACCESfor that argv[0]. A baresyscall: 'spawn'without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when onerunnerFailureRulesentry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structuredSANDBOX_UNAVAILABLEwith spawn or matched-line detail; an asynchronously rejected or settled background job stampssandbox.runnerFailedand renders its own marker. ASubprocessRuntimethat synchronously throws the sameENOENT/EACCESshape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. - What happens on a platform with no backend — Windows today?
confine()throws the fail-closedSANDBOX_UNAVAILABLEand the command never spawns;win32is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). bwrapis installed on my host but unusable (disabled unprivileged userns, an LSM denyingmount) — what happens? The chain probe is functional — it builds and enforces a real profile rather than checking--version— so a present-but-unusablebwrapfails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime.- Does the sandbox restrict network or process visibility? No —
SandboxModeclaims 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.shell— the bash tools, and hook commands transitively — plus the filesystem tools (read/write/edit) through the sandboxedctx.fsprovider (the cross-family fs sandbox RFC): bash confines via the OS runner, fs via an in-process path fence, both keying off the samectx.sandboxPolicymode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - Does a granted escalation persist? No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through
job_outputand may ground a new exact-command retry. - When does a runtime mode switch take effect? Once its session event commits, the next pre-step policy-context reconciliation and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use.
- 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 enters the next full policy-context message. - 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 reportfull.
In-repo precedents this design copies or contrasts with:
- The capability-seams Agent Note — the Service Definition / Service Provider / Consumer split and the "don't split preemptively" timing rule the second consumer satisfied.
- The
dsh-shellrequest/spec split (the bash vocabulary catalog) — the completesandboxPolicyrides its per-call carrier, and the explicit-resolve()defaulting convention. - The approval seam Agent Note — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there.
- Event-sourced sessions and standalone log-only events — the log-as-store foundation the per-session modes fold over, and the explicit durability boundary the anchoring design obeys.
- The interception extension-points Agent Note — the
tools/pre-executevocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own).