Sandboxing in dsh is process confinement against a file-effect policy — the sandbox seam wraps an exact argv under a host-path allow/deny rule set. It is one of the same-world capability seams (it shares the host kernel and filesystem; containers and microVMs replace the surrounding seam instead). This page maps the whole area: the service definition, the local runner chain, the policy owner, and the enforcing providers that plug in. The Linux Landlock runner gets its own page in Landlock.
| Package | Role |
|---|---|
packages/sandbox/sandbox | ctx.sandbox service definition + escalation vocabulary |
packages/sandbox/sandbox-local | Local backend: picks the platform runner chain |
packages/sandbox/sandbox-policy | ctx.sandboxPolicy: the one policy home (mode + workspace root) |
packages/sandbox/sandbox-windows-acl | Windows restricted-token / ACL backend |
packages/fs/fs-sandbox | ctx.fs sandbox-enforcing implementation |
packages/shell/bash-sandbox | ctx.shell sandbox-enforcing bash executor |
packages/subprocess/subprocess | ctx.subprocess service the providers rely on |
native/landlock-run | Prebuilt landlock-run binary family (entry + -linux-x64/-linux-arm64) |
The sandbox seam: one service, many providers
The Service Definition lives in packages/sandbox/sandbox/src/index.ts. It declares the abstract SandboxProvider registered as ctx.sandbox, plus the three environment types every consumer and backend share. It is deliberately process-shaped: the one verb is confine.
export abstract class SandboxProvider extends Service {
constructor(ctx: Context) { super(ctx, 'sandbox') }
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}confine takes the exact argv the caller is about to spawn (never a shell string — a shell-shaped consumer passes ['bash', '-c', command]) and returns the argv to spawn instead prepended with the runner, plus classification facts. The contract is fail-closed: confinement must be enforced, or confine cannot return unconfined argv. When no backend is usable it throws SandboxUnavailableError with code SANDBOX_UNAVAILABLE.
The ConfinedArgv result carries three kinds of facts consumers use to interpret a failed run:
enforcement: 'full' | 'partial'— how completely the selected backend governs every promised file effect (partial on older Landlock ABIs or the Windows ACL backend);denialSignatures— the case-insensitive stderr substrings a denied file effect produces on this backend's dialect (EROFS text under bwrap,permission deniedunder Landlock,operation not permittedunder Seatbelt,access is deniedunder Windows);runnerFailureRules— evidence that a runner died before execution (e.g.bwrap:or a versioned exit-125 line), so "the command never ran" is never misclassified as "confinement blocked it".
The mode vocabulary
SandboxMode is a closed union of three file-effect modes:
| Mode | Meaning |
|---|---|
read-only | Only required sinks (e.g. /dev/null) are writable; the sandboxed process may not write anything else |
workspace-write | Also writable under the workspace root plus backend-defined temp areas |
danger-full-access | Confinement bypassed entirely |
ConfinedSandboxMode is the subset excluding danger-full-access. Network access and process visibility are deliberately outside this vocabulary — the mode promises only file effects.
What sandboxing covers
- Process execution — the exact wrapped
argv, confined by the kernel runner or the in-process fence. - Filesystem — reads pass freely; writes are governed by the mode (see Filesystem observation & sandbox policy).
- Network: not confined here. The docs are explicit that network access is outside the mode's file-effect promise, so it relies on other seams/credentials.
Per-call policy, not a global switch
sandbox-policy's central idea: the policy is resolved once per capability call (ctx.sandboxPolicy.resolve()), not fixed on the provider. Two consumers can be confined under different policies at the same instant, and an approved escalation is a new call with a wider policy.
The policy home is packages/sandbox/sandbox-policy/src/index.ts. SandboxPolicyService (registered ctx.sandboxPolicy) owns the deployment default mode, the fallback workspace root, and per-session resolution. Its plugin config is the one shared policy config:
export interface Config {
mode?: SandboxMode // default 'read-only' — the fail-safe default
workspaceRoot?: string // fallback root, default process.cwd()
}Resolution precedence (highest first): an approved explicit mode override from a single call → the session's last sandbox/mode event → the deployment default. The workspace-write root is the session's immutable cwd; a configured root is the fallback for agentless calls.
Per-session overrides live in the session log as sandbox/mode events (packages/sandbox/sandbox-policy/src/session-mode.ts) — setSandboxMode(session, mode) appends the event, effectiveSandboxMode(events) folds it, replaying the log is the state. Sessions never share state, and there is no external config store.
The policy service also contributes a runtime-context snapshot (sandbox:policy, order 110) so the model sees the current mode and root in its context without a system-prompt rewrite.
The local runner chain (sandbox-local)
LocalSandboxProvider (packages/sandbox/sandbox-local/src/index.ts) is the default backend. It selects a runner by platform first, functional probes second:
| Platform | Chain (preference order) | Enforcement claimed |
|---|---|---|
linux | bwrap → Landlock launcher | full / probe-dependent |
darwin | sandbox-exec (Seatbelt) | full |
win32 | Windows ACL restricted-token runner | partial |
A chain with a single candidate is selected without a probe (its execution-time refusal still fails closed); multiple candidates are arbitrated by functional probes (defaultProbeBwrap, the Landlock probe, defaultProbeSeatbelt, defaultProbeWindowsAcl), each at most once and cached for the provider lifetime. A platform with no chain throws SandboxUnavailableError.
Yes — sandbox-local invokes landlock-run. On Linux, after bwrap proves unusable, landlockProfileArgs (packages/sandbox/sandbox-local/src/profiles.ts) builds --ro / plus --rw /tmp and --rw <workspaceRoot> under workspace-write, and confine spawns [launcher, ...grants, '--', ...argv]. The launcher path, probe, and grant-argv construction all come from @deepseek-ai/node-addon-landlock-run, covered in Landlock.
Each runner speaks its own profile dialect (profiles.ts): bwrap uses --ro-bind / / + --dev /dev + --proc /proc + --die-with-parent, plus --tmpfs /tmp + --bind <workspaceRoot> <workspaceRoot> under workspace-write; Seatbelt emits an SBPL profile whose writable roots come from the shared writableRoots helper; the Windows runner receives --workspace, --temp, --mode, and (with a session) --write-sid/--temp-write-sid grant flags.
writableRoots (packages/sandbox/sandbox/src/roots.ts) is the single home for "what workspace-write may write": the canonicalized workspace root, /tmp, and os.tmpdir(). The Seatbelt profile and the in-process fs fence both derive their allow-list here, so the write tool and bash can never disagree about whether /tmp is writable.
Windows story
windows-acl grants are seam-managed in sandbox-local. The workspace write SID is derived per-workspace (workspaceWriteSid, a standing ACE cached for the server lifetime and never revoked); each live session/workspace pair gets a random private temp directory with its own SID (tempWriteSid), revoked on provider dispose. The runner reports partial enforcement because WRITE_RESTRICTED must retain Everyone in its restricting lists and NTFS hard links can alias one underlying file object across paths.
Enforcing providers plug in at the consumer boundary
Three consumers mount the sandbox seam ahead of their own backends and are swapped in via cordis.yml composition:
fs-sandbox—SandboxedFileSystemextendsLocalFileSystemand registers asctx.fs. It adds a per-call policy fence on the two mutations (writeText,editText); reads pass through untouched. A denied mutation throws the structuredFS_SANDBOX_DENIED, mapped by the tool layer to the model-facing[sandbox: …]marker. This is an in-process fence in trusted code over a model-controlled path — kernel isolation of untrusted code staysctx.shell's job.bash-sandbox—SandboxBashExecutorextendsLocalBashExecutorand registers asctx.shell. It wraps['bash', '-c', command]throughctx.sandbox.confine, then classifies the result's stderr against the backend's denial/runner-failure signatures. It requiresctx.sandboxandctx.sandboxPolicy.pwsh-sandbox— the same pattern for PowerShell.
Both executors inherit their local backends' mechanics but resolve the sandbox policy from ctx.sandboxPolicy; the runner choice remains the ctx.sandbox provider's config. The tool layer (tool-fs, tool-bash) owns approval and passes a complete per-call policy — it is the decision point for the escalation vocabulary in the next section.
Escalation: the sandbox_permissions ladder
The escalation.ts module is the shared choreography for every enforcing tool family: the strictly-wider ladder, argument validation, and approveEscalation — the ordered fail-closed sequence that resolves a sandbox_permissions request through ctx.approval before anything executes.
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']validateEscalationArgs enforces that sandbox_permissions and justification travel together (a reason with no widening, or widening with no reason, is a malformed ask). sandboxDenialMarker(mode) returns the exact model-visible denial line ([sandbox: file access denied under <mode> mode]), shared by bash and fs so the model recognizes a denial identically; escalationHintMarker(subject) returns the same-turn retry nudge. Widening is always checked at execution, never baked into a tool schema (the schema enum is the closed target vocabulary; the effective mode is per-call truth).
Platform support matrix
| Platform | Primary runner | Enforcement | Notes |
|---|---|---|---|
| Linux | bwrap; Landlock as fallback | full (partial on old Landlock ABI) | bwrap needs a usable unprivileged namespace/mount profile |
| macOS | sandbox-exec (Seatbelt, SBPL) | full | Apple ships the CLI; probe fails closed if it ever disappears |
| Windows | ACL restricted-token runner | partial | Everyone-in-restricting-list + hard-link boundary |
On platforms with no chain (or when every candidate probe fails), confinement fails closed with SANDBOX_UNAVAILABLE — a command never runs unconfined unless the consumer explicitly selects danger-full-access. See also the Landlock support matrix.
Further reading
- Landlock: the native runner — the Linux
landlock-runbinary, its CLI contract, and its support matrix. - Filesystem observation & sandbox policy — the write/edit fence, the
fs/*events, and how the policy answers "can this tool write here?". - Permissions & approval — how an escalation triggers a
ctx.approvalrequest and the approval pipe is resolved. - Guards: timeout & repeat reminders — the cooperating pipes that terminate or inject alongside the sandbox.
packages/sandbox/sandbox/src/index.ts— theSandboxProviderservice definition and the mode vocabulary.packages/sandbox/sandbox-local/src/index.ts— theLocalSandboxProviderrunner chain and its probes.