DSH / Atlas
2026-07-06implementedfeature

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

子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式

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

English

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. 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.

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. 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.

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.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 knobs

The 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.

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 one-shot policy 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 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.

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 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.

The bash consumer

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.

Escalation: one approved wider retry after a denial

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.

Per-session modes: the session log as the store

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.)

In-process tools

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.

Testing

  • 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_ENV ordering, 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 bash false as 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.

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.

  • 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 the main repository under native/ following the @deepseek-ai/node-addon-landlock-run template, 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.

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 structured runnerFailureRules 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 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-builtin model, 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-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 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/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 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 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.

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.
  • 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 SessionEventMap merging, inbox mutation from agent/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-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 became 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 sandboxed ctx.fs provider and relocates the mode/root config and the sandbox/mode override to ctx.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-exec CLI. 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 become SANDBOX_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-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, through the spawn channel when the selected executable cannot start, or through a structured rule when a started runner refuses — while a granted danger-full-access run 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.

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? 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 ENOENT or EACCES for that argv[0]. A bare syscall: '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 one runnerFailureRules entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured SANDBOX_UNAVAILABLE with spawn or matched-line detail; an asynchronously rejected or settled background job stamps sandbox.runnerFailed and renders its own marker. A SubprocessRuntime that synchronously throws the same ENOENT/EACCES shape 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-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 packaged Landlock launcher, and the verdict is cached for the provider's lifetime.
  • Does the sandbox restrict network or process visibility? SandboxMode claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host /proc/<pid> magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged (decision). 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 sandboxed ctx.fs provider (the cross-family fs sandbox RFC): bash confines via the OS runner, fs via an in-process path fence, both keying off the same ctx.sandboxPolicy mode. 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_output and 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 report full.

Prior art

In-repo precedents this design copies or contrasts with:

中文

问题

一个编码 agent(智能体)需要如下产品路径:bash 子进程(以及依附其上的钩子命令)默认在受限的文件沙箱下执行;当且仅当沙箱实际拒绝了某个操作时,模型可以为同一操作请求一次用户批准,获批后以更宽的权限重试一次。本设计刻意不声称覆盖所有工具:fs/web/todo 在进程内执行,execve 包装层对它们毫无意义(§ 进程内工具);跨工具族的统一边界属于分阶段后续工作(§ 延迟阶段)。如果没有共享词汇,每个工具都会各自重新发明批准字段、拒绝解析、重试匹配和权限状态提示。

harness 是一个 SDK,因此约束必须是开发者可组合的能力:是否启用沙箱、每个平台使用哪个后端,都应作为一等条目写在叶子 cordis.yml 中,而非藏在某个执行器的私有机制里。而首选 runner bwrap 恰恰在沙箱最重要的主机上不可用(精简容器、禁用了非特权 userns、LSM 拒绝 mount),因此备选 runner 必须随 SDK 一起交付,而不能假设主机已有。

仅有约束还留下两个缺口。拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 workspace-writedanger-full-access,从而使沙箱形同虚设。而沙箱模式和批准策略也会通过部署配置或可选的 UI 策略控件在 agent 生命周期内变化;执行与模型可见的策略必须派生自同一份已记录的状态。

决策

一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 cordis.yml 组合而来;不触及 agent-loop。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——subagent-acp 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。

部署方式

四条 cordis.yml 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;examples/acp-agent 默认使用此组合:

- 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 knobs

这一替换对 ctx.shell 的所有消费方透明:bash 工具、钩子命令和后台任务照常运行,直接使用提供方返回的已包装 argv 启动。删除 sandboxpermission 条目、将 bash 替换为 @deepseek-ai/dsh-bash-local 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 approval 则保留约束但以自身错误文本关闭每次升级;permission 还要求 approval seam 和约束执行器同时存在,因此组合不完整的 preset 层会在加载时明确报错。

配置错误会显式导致失败:mode 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 confine() 阶段抛出结构化的 SANDBOX_UNAVAILABLE,而非降级为无约束执行。如果所选 runner 以可归因的 ENOENTEACCES 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。dsh-sandbox-local 上的 runnerCommand 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。

被拒绝的文件操作返回 [sandbox: file access denied under <mode> mode] 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 sandbox_permissionsjustification 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 the user rejected escalating this command to "<mode>",且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 dsh-permission-presets 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 customACP(Agent Client Protocol)自动化组合不挂载该 UI 服务,而是显式选定其部署模式。

设计细节

范围界定

OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还将适用于 ACP subagent 子进程。文件系统、web 和其他工具在进程内执行,需要在各自的 seam 层面实施策略;argv 包装层无法约束一个闭包了 ctx 的函数。既有的 bash request/spec 拆分承载按调用的覆盖,而 tools/pre-execute 和 approval seam 负责一次性策略决策。

seam:ctx.sandbox

dsh-sandbox 负责定义词汇和 SandboxProvider 约定:confine(argv, policy) 返回调用方应当 spawn 的替代 argv(经过包装,使进程及其所有子进程在约束下运行),加上所选后端达到的 enforcement 完整度、其拒绝方言(denialSignatures,该后端内核在拒绝文件操作时打印到 stderr 的子串),以及其结构化 runner 失败证据(runnerFailureRules,可选的允许退出码加上排除整行精确信息性行后按行匹配的致命签名);没有可用后端时抛出失败关闭的 SANDBOX_UNAVAILABLE 错误,绝不静默放行。词汇:SandboxModeread-only / workspace-write / danger-full-access,仅限文件操作——不声称覆盖网络和进程可见性)、SandboxEnforcementfull / partial)、SandboxExecutionPolicy(每次能力调用的完整 mode + 工作区根目录)以及 SandboxPolicy(提供给约束后端的子集)。

策略随每次调用而非提供方携带:两个消费方可以在同一时刻以不同策略约束(bash 在 read-only 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。

该 seam 仅约束与宿主共享文件系统和内核的子进程。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力 seam(ctx.shellctx.fs)的 Service Provider,因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。

留待需要时再决定:网络限制是作为独立的 network_mode 到来,还是在某个 runner 同时强制两者后合并进 sandbox_mode;以及 SandboxPolicy 是现在就增加额外的可写根授权(launcher 已支持 --rw <path>),还是等到升级机制需要时再加。

本地后端与随附 launcher

dsh-sandbox-local 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 bwrap 然后 Landlock;macOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败规则,以便 dsh-bash-sandbox 区分被拒绝的文件操作与损坏的沙箱。Landlock runner 失败需要退出码 125,加上一行不是通知的 landlock-run: 诊断;即使子进程以 1、2 或 125 退出,精确匹配的部分强制执行通知仍只是信息。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。runnerCommand 保留面向运维人员的 runnerFailureSignatures 配置,要求其中条目均为非空单行,并将它们映射为一条内部致命规则。消费方会直接 spawn 每个返回的 argv,因此缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本会通过可归因的 ENOENTEACCES spawn 通道失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。运维人员配置的脚本必然要先启动解释器,再应用自身 profile。

launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 约定):--ro <path> / --rw <path> 授权,--,被包装的 argv;它为自身安装规则集并执行 exec(规则集跨 execve 继承,且它在限制前设置 no_new_privs);--probe 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 landlock-run: 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 landlock-run: partial enforcement (older Landlock ABI) 通知,因此该行不是致命证据。

Landlock launcher 源码和包家族位于 native/landlock-run,与 harness 消费方同仓,并属于根 pnpm workspace。仓库内 Landlock 发布决策负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。

后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 /dev/null;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。

bash 消费方

dsh-bash-sandbox 扩展 LocalBashExecutor,把精确的 ['bash', '-c', command] argv 交给 ctx.sandbox,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 BASH_ENV 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,Node 报告 ENOENTEACCES,并且错误符合以下一种形态时,才判定为 runner 失败:error.path 等于提供方返回的 argv[0],同时 syscall'spawn' 或精确的 'spawn <runner>';或者 error.path 不存在,同时 syscall 为精确的 'spawn <runner>'。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 SANDBOX_UNAVAILABLE 并附上原始详细信息;异步后台拒绝则盖章 runnerFailed: truedenied: false。如果 SubprocessRuntime 同步抛出同样能指明 runner 的形态,后台启动会抛出 SANDBOX_UNAVAILABLE;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 SANDBOX_UNAVAILABLE,并以该致命行作为详细信息;结算后的 ShellProcess 会盖章 sandbox.runnerFailed,bash 生产者再通过通用 job_output 渲染它。

模型会在归属方派生的 sandbox:policy 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记([sandbox: file access denied under <mode> mode]),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。当前策略决策负责该上下文的理由与边界。

升级机制:拒绝后一次经批准的更宽重试

ShellExecRequest.sandboxPolicy 是可选的完整按调用输入;解析后的 spec 使该字段显式。ShellExecutor.sandboxMode 仍是公布已挂载执行器能否兑现该策略的能力事实,因此只有约束组合才暴露升级。seam 接受任何显式策略;工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。

ctx.sandboxPolicy.resolve() 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 SessionHeader.cwd > 配置的后备根目录。SandboxBashExecutor.resolve() 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 run()/start() 永不读取可变会话状态。每进程包装事实以返回的 ShellProcess 为键;onProcessDone() 会通过 stderr 分类之外的通道接收 spawn 失败,并在 done 结算前给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。

当约束执行器被挂载时,bash 公布配对的 sandbox_permissionsjustification 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。allowed-once 仅将授权模式盖章到该请求上,而 rejectedcancelledunavailable、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。

升级是对被拒绝命令的同轮次重试,使用最窄的足够 sandbox_permissions 和一个 justification;批准提示词是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。dsh-tool-bash 拥有请求动作,因为执行器 Service Definition 既没有 agent 也没有用户交互所需的 call id。

仍未决定:持久授权超出沙箱模式之外的作用域标识是什么——确切调用、路径、命令前缀、会话或时间窗口——这是公布 allow_always 选项之前必须回答的问题。

按会话模式:会话日志即存储

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

默认值是组合配置(cordis.yml)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启后仍然有效以及多会话隔离均由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值(决策)。

每个旋钮一种事件,由其领域拥有——这是每个既有事件族已遵循的可合并扩展 SessionEventMap 惯用法(dsh-user-approval 中的 approval/*、hooks 包中的 hook/*):

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

每个拥有者导出相同的三件套:事件声明、纯 fold(effectiveSandboxMode(events) / effectiveApprovalPolicy(events)——一个 findLast,类型化到领域的封闭联合),以及唯一的写入路径(setSandboxMode(session, mode) / setApprovalPolicy(session, policy)——切换即其事件;没有任何东西在带外修改状态)。无需共享的归属服务、通用 facts map 或注册表:第三个配置项只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 'never' 门控是批准 Agent Note 同一模式的另一侧。

每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 next-step inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 user/message'ask''never' 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。

可选的 UI 界面PermissionPresetService:一张部署定义的 preset 表,每个条目捆绑一个沙箱模式与一个批准策略。随附的 workspace-writedanger-full-access preset 写入两个领域 setter;preset 表之外的旋钮组合报告为 custom。UI 适配器可以把该表暴露为选择器。仅面向自动化的 ACP 传输层不公布任何配置选择器,也不挂载权限 preset 服务。

已提交的事件是提交边界。 运行时切换在目标会话上记录其 preset 和发生变化的旋钮事件,之后每次能力解析都折叠最后的值。选择有效的会话追加边界由适配器负责;ACP 传输层没有运行时切换路径。(原先 ACP 的空闲切换锚定——将待定的空闲选择保留到下一次提示词提交——已随该桥一并移除。)

进程内工具

fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(dsh-fs-sandbox 按模式限制 write/edit;见跨工具族 fs 沙箱 RFC),因此 read-only/workspace-write 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装层——后续设计选择了一个共享策略归属 ctx.sandboxPolicy,由各能力强制,而不是统一包装层。

测试

  • 单元测试: 固定平台选择和 profile、直接交接提供方返回的 argv、带有无效 workdir 对照的 spawn 层失败、runner 缺失/不可执行/解释器缺失证据、格式错误 runner 阴性对照、受约束的 BASH_ENV 求值顺序、结构化 runner 分类(包括只有部分强制执行通知的子进程结果、带门控的致命证据、子进程退出码 126/127,以及前台/后台一致性)、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传,以及运行时上下文排序与具体化。
  • Keyless 真实 runner: 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。打包安装测试会安装当前 checkout 的原生 tarball,并证明 launcher 保持可执行且字节完全一致。CI 拒绝静默全跳过。
  • With-key: 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。
  • 快照: 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 /tmp,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。一个模拟 Landlock 部分强制执行行为的 POSIX 提供方会在组装后的应用中固定直接执行 bash false 时仍得到普通子进程结果,并固定提供方可执行文件缺失时在前台/后台均为基础设施失败。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。

延迟阶段

每个阶段在开始实施时都会完成完整设计,并对照当时的代码验证,同时在其涉及的层级配套单元测试、真实 API e2e 和快照覆盖。

  • 第二个消费方——subagent-acp 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。
  • 更多环境——环境一致的能力组示例(如 bash+fs 对一个容器)。
  • Windows 链——PLATFORM_CHAINS.win32 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 native/ 下按 @deepseek-ai/node-addon-landlock-run 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案经考虑后已驳回——它所经受的实战检验还不足以承载安全不变式。

曾考虑的替代方案

  • 命令字符串启发式预检:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。
  • 即使平台仅有一个后端也功能性探测:否决。探测用于在候选者之间仲裁;只有一个时无需决策,且探测开销对每个会话的首次约束命令征税(对未来重量级后端而言代价过高)。runner 自身执行时的失败关闭拒绝加结构化 runnerFailureRules 分类承载了安全属性。
  • 提交构建好的 launcher 二进制:否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + 主仓库的字节固定发布演练使二进制远离所有代码树。
  • 安装时编译 launcher:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。
  • 从一个构建器交叉编译两种架构:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链(rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(node-addon-require-builtin 模式,由主仓库的原生流水线保留)。
  • 无备选(bwrap 或失败关闭):否决。将失败集中在沙箱最重要的主机上,最终因放弃而降级到 danger-full-access
  • 将机制保留在 dsh-bash-sandbox 内部:否决。阻塞既有的第二个消费方,使未来阶段从一个 bash 插件的配置中读取模式,且无法表达升级。
  • 提供方上的配置固定模式:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。
  • 一个接口同时覆盖容器/VM:否决。confine(argv) 预设共享文件系统;环境隔离是作为一致组部署的能力兄弟后端。
  • 通用 ToolRuntime 包装任何工具:否决。对进程内工具(闭包了 ctx)机械上不成立;声明式效果重写对 fs/web/todo 而言不合理。
  • 在执行器内部(dsh-bash-sandbox)请求批准:否决。没有可路由的 agent,没有可附加提示词的 callId;添加它们会让传输边界了解会话和 UI——工具层持有两者并拥有面向模型的词汇。
  • 同一工具调用内自动重试:否决。日志无法重建的隐藏重入:一个 tool/call 会产生两次具有不同策略的执行——重试是一次新的带有自身参数和结果事实的已记录调用。
  • 无条件公布升级字段:否决。在 dsh-bash-local 下它们是死杠杆——公布 harness 无法兑现的选项会制造注定失败的授权;能力门控仅需注册时一次读取。
  • 默认值相对的升级阶梯(仅公布比执行器注册时默认值更宽的模式):否决。按会话覆盖使默认值成为错误的基线——切换到比默认值更窄的会话恰恰失去它需要的杠杆,而在 danger-full-access 默认值下字段完全消失,同时一个被覆盖为 read-only 的会话仍处于约束中却没有升级路径。枚举固定封闭的目标词汇;严格放宽是针对会话有效模式的按调用执行检查。
  • 按会话动态工具 schema:否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照约定),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 和每次切换的 header 变动。
  • 将重试硬匹配到先前的拒绝:否决。命令字符串同一性脆弱(引号、workdir、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 allow_always 授权存储需要机器可检查的范围时才重新考虑。
  • 通用 env/state facts map 加拥有者服务:否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 findLast,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。
  • 通过 agent.inject() 加总线事件逐次叙述切换:否决。独立通知会暴露归属方顺序和中间组合,而一次 pre-step 组合可以原子排队完整的当前状态。
  • 在稳定系统提示词中声明沙箱模式:先行交付,随后根据线上证据移除:每次请求都带有 Bash commands run under the "read-only" file sandbox. 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。当前策略决策取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。
  • 用专门的簿记事件追踪「上次告知」:否决。会话历史记录模型看到的确切策略上下文,已领取批次与待处理 inbox 条目则表明正在进入或已经排队的内容。重新计算目标消息取代了第二条簿记流——事件仅在它们本身即为存储时才需要。
  • 相互独立的沙箱与批准选择器:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。

后果

已交付并固定的内容——测试中的各层级分别保障:

  • 被拒绝的命令以 sandbox_permissions + justification 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
  • 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
  • 一条带来源的策略上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文消息、header、旋钮事件、批准通知、批准与结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件。
  • 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一次 pre-step 会原子 upsert 两个当前值,已提交的沙箱切换由下一次调用的盖章兑现。
  • 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。
  • 两个并发会话永远看不到彼此的状态或通知。
  • 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。
  • 策略归属仍通过 SessionEventMap 合并、从 agent/pre-step 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。

代价与已接受的限制:

  • 单一包装层的幻觉被有意放弃。tools/pre-execute 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。
  • read-only 通过后续设计成为跨工具族边界。 本 Agent Note 最初只交付 bash 强制;跨工具族 fs 沙箱 RFC 通过沙箱化的 ctx.fs 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 sandbox/mode 覆盖迁移到 ctx.sandboxPolicy(§ 进程内工具)。
  • Windows 后端只提供部分强制执行。 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 Windows ACL 沙箱决策以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 enforcement: 'partial',绝不提升为完整承诺。
  • Seatbelt 层级依赖 Apple 已弃用但仍交付的 sandbox-exec CLI。 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 SANDBOX_UNAVAILABLE,且命令绝不会运行;失败关闭,绝不开放。
  • Landlock 约束的完整度取决于运行内核的 ABI。 报告为 enforcement: 'partial' 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。
  • Runner 归因使用带内协议。 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。
  • launcher 在源码中是 workspace 依赖,发布后是 NPM 依赖。 主仓库会在发布同一个包家族之前,一起测试经审查的 C 源码、原生 CI 构建和字节固定的本地 tarball;真实内核 e2e 测试环节会验证这些安装字节的实际行为。
  • 模型可能过度请求。 在没有拒绝依据的情况下升级,或在 workspace-write 足够时选择 danger-full-access:描述引导且枚举强制阶梯,但人的提示词是实际门控;approval/asked 原因使过度请求可审计,且 prepend 策略应答器可以自动拒绝部署永远不想要的模式。
  • 公布的目标集是静态的,而有效模式是按会话的(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。
  • 授权的升级不等于可工作的沙箱。 不可用的后端即使对授权升级到约束模式也仍然失败关闭——平台没有链或所有探测失败时在 confine() 阶段失败,所选可执行文件无法启动时通过 spawn 通道失败,已启动的 runner 拒绝时则通过结构化规则失败——而授权的 danger-full-access 运行根本不触及提供方:此时授权(而非探测)是权威。
  • 运行时上下文历史仅追加。 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息。
  • 旧策略快照仍保留在历史中。 每份完整快照都会明确取代更早的运行时上下文快照,因此回放与压缩(compaction)只需保留最新具体化的消息。

FAQ

  • 一个命令返回了 [sandbox: file access denied under read-only mode]——它失败了吗? 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。
  • 如何区分损坏的沙箱与失败的命令? 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 ENOENTEACCES 时,才能据此判定 runner 损坏。没有精确错误路径的裸 syscall: 'spawn' 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 runnerFailureRules 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 SANDBOX_UNAVAILABLE,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 sandbox.runnerFailed 并渲染自己的标记。如果 SubprocessRuntime 同步抛出同样带有 runner 路径的 ENOENTEACCES 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
  • 在没有后端的平台上会发生什么——今天的 Windows? confine() 抛出失败关闭的 SANDBOX_UNAVAILABLE,命令永不 spawn;win32 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。
  • bwrap 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 mount)——会发生什么? 链探测是功能性的——它构建并强制一个真实 profile 而非检查 --version——因此存在但不可用的 bwrap 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。
  • 沙箱限制网络或进程可见性吗? SandboxMode 只声称文件影响,并且没有后端声称限制网络。进程可见性因后端而异:bwrap 会取消共享 PID 命名空间,并挂载与其匹配的 procfs,因为宿主 /proc/<pid> 的魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变(见相关决策)。网络限制是否成为自己的旋钮留在 § seam 中开放。
  • 哪些工具实际在约束下运行? 通过 ctx.shell 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 ctx.fs 提供方运行的文件系统工具(read/write/edit,见跨工具族 fs 沙箱 RFC):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 ctx.sandboxPolicy 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。
  • 授权的升级会持久化吗? 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 job_output 呈现,并且可以作为一次新的精确命令重试的依据。
  • 运行时模式切换何时生效? 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。
  • 重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢? 覆盖从会话日志回放(effective = fold ?? config),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。
  • 结果上的 enforcement: 'partial' 是什么意思? 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 full

先例

本设计复制或对比的仓库内先例:

  • 能力 seam Agent Note——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。
  • dsh-shell 的 request/spec 拆分(bash 词汇目录)——完整的 sandboxPolicy 搭载其按调用载体,以及显式 resolve() 默认约定。
  • 批准 seam Agent Note——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。
  • 事件溯源会话独立纯日志事件——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。
  • 拦截扩展点 Agent Note——tools/pre-execute 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。