Skip to content

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.

PackageRole
packages/sandbox/sandboxctx.sandbox service definition + escalation vocabulary
packages/sandbox/sandbox-localLocal backend: picks the platform runner chain
packages/sandbox/sandbox-policyctx.sandboxPolicy: the one policy home (mode + workspace root)
packages/sandbox/sandbox-windows-aclWindows restricted-token / ACL backend
packages/fs/fs-sandboxctx.fs sandbox-enforcing implementation
packages/shell/bash-sandboxctx.shell sandbox-enforcing bash executor
packages/subprocess/subprocessctx.subprocess service the providers rely on
native/landlock-runPrebuilt 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.

ts
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 denied under Landlock, operation not permitted under Seatbelt, access is denied under 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:

ModeMeaning
read-onlyOnly required sinks (e.g. /dev/null) are writable; the sandboxed process may not write anything else
workspace-writeAlso writable under the workspace root plus backend-defined temp areas
danger-full-accessConfinement 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:

ts
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:

PlatformChain (preference order)Enforcement claimed
linuxbwrap → Landlock launcherfull / probe-dependent
darwinsandbox-exec (Seatbelt)full
win32Windows ACL restricted-token runnerpartial

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-sandboxSandboxedFileSystem extends LocalFileSystem and registers as ctx.fs. It adds a per-call policy fence on the two mutations (writeText, editText); reads pass through untouched. A denied mutation throws the structured FS_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 stays ctx.shell's job.
  • bash-sandboxSandboxBashExecutor extends LocalBashExecutor and registers as ctx.shell. It wraps ['bash', '-c', command] through ctx.sandbox.confine, then classifies the result's stderr against the backend's denial/runner-failure signatures. It requires ctx.sandbox and ctx.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.

ts
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
  'read-only': ['workspace-write', 'danger-full-access'],
  'workspace-write': ['danger-full-access'],
}
ts
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

PlatformPrimary runnerEnforcementNotes
Linuxbwrap; Landlock as fallbackfull (partial on old Landlock ABI)bwrap needs a usable unprivileged namespace/mount profile
macOSsandbox-exec (Seatbelt, SBPL)fullApple ships the CLI; probe fails closed if it ever disappears
WindowsACL restricted-token runnerpartialEveryone-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-run binary, 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.approval request 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 — the SandboxProvider service definition and the mode vocabulary.
  • packages/sandbox/sandbox-local/src/index.ts — the LocalSandboxProvider runner chain and its probes.