Sandbox: From seatbelt to the Execution World
ctx.fs and ctx.subprocess share one path namespace, so the execution environment can be swapped as a whole. Core source: packages/sandbox/, packages/e2b/, and native/landlock-run/.
ctx.sandbox only covers same-kernel child processes with the host while containers and remote execution take another path; and how much code you must change to swap the whole execution environment from local to a remote E2B sandbox (spoiler: zero consumer changes).
Play first, then lecture. Scenario A demos swapping worlds: the top row is bash, PTY, LSP — the components that do the work — and they only connect to the two middle sockets. Hit “Switch to E2B” and watch whether any line breaks when the world under the sockets swaps as a whole. Then hit “Anti-pattern” and see how ugly a no-socket architecture looks when you swap worlds. Scenario B is the denial decoder: from the same stderr error, how do you tell sandbox blocked it vs the sandbox itself broke.
packages/e2b/README.zh.md and docs/subsystems/filesystem.zh.md; Scenario B maps to the two orthogonal classifiers denialSignatures and runnerFailureRules at lines 95–116 of packages/sandbox/sandbox/src/index.ts.Up front: DSH splits the sandbox into three layers, each owning its job.
ctx.sandbox.confine(argv, policy) does one thing: wrap the argv you want to spawn with a confinement runner. Linux uses bwrap or Landlock, macOS uses Seatbelt (sandbox-exec), Windows uses an ACL-restricted token. Premise: the child shares filesystem and kernel with the host.
read / write / edit never spawn a child process, so wrapping argv is meaningless. fs-sandbox checks policy inside trusted code: normalize paths, verify containment, and on deny throw structured FS_SANDBOX_DENIED. It already knows what it denied — no guessing from kernel stderr text.
Containers, microVMs, and remote execution are peer replacements of the whole capability seam — ctx.sandbox never even enters the picture (docs/subsystems/sandbox.zh.md line 5). To go remote, swap the implementations behind the ctx.fs and ctx.subprocess sockets.
An easy Layer-1 mistake: sandbox policy is a per-call parameter that rides along — never welded into provider global state. ctx.sandboxPolicy.resolve() resolves a full policy for every capability call (mode + workspace root + session id). Explicitly approved elevation beats session settings, which beat deploy defaults. So two sessions in one process — one read-only, one workspace-write — can ask the same provider for different boundaries without interfering; an approved elevation retry is just a new call with a wider policy, and provider state did not change.
Linux’s Landlock backend deserves its own note. DSH skipped off-the-shelf wrappers and wrote landlock-run: ~300 lines of C11, musl-static, installs the Landlock ruleset on itself then execs the target — rules inherit across execve, so every descendant stays confined. If the kernel can’t support it, it exits without running the command. Binary contracts (argv syntax, exit codes, report lines) are locked in native/landlock-run/docs/cli-contract.md; probe() returns full / partial / unusable, and old kernel ABIs only report partial. One detail: launcher failure’s conventional exit is 125, but a successfully exec’d child can also exit 125 on its own — so exit code alone never settles the case; you must also see a fatal diagnostic line. That leads to the two classifiers below.
denial ≠ runner failureThe wrapped argv from confine() carries two stderr classifiers. Check runnerFailureRules first: a fatal signature means the runner itself died — the command never ran — report as sandbox infrastructure failure. Then denialSignatures: a match means the sandbox worked and blocked an out-of-bounds op. Order must not reverse.
Denial phrases match backend dialectbwrap’s read-only mounts speak EROFS text, Landlock speaks EACCES, Seatbelt speaks EPERM. Consumers match only the phrases the current backend declares — no cross-backend union, because a union would claim denials a backend never produces.
partial is not fullEnforcement integrity is a fact the backend reports. Old Landlock ABIs and Windows ACL Everyone / hard-link gaps can only report partial. Consumers that need absolute boundaries must handle the distinction explicitly — no looking away.
Under a restricted policy, silent unconfined passthrough is never legal. If a sandbox was required but no backend is available, confine() throws SandboxUnavailableError, the whole call fails, and not a single command line runs. The error type itself nails the stance: first sentence is “refusing to run the command unconfined”, then per-platform fix hints — Linux install bubblewrap or switch to a kernel with Landlock enforcement, macOS confirm sandbox-exec works, Windows confirm the ACL-restricted token runner can start; if you really won’t fix it, flip the consumer explicitly to danger-full-access and put “unprotected” in black and white.
The exception extends HarnessError, carries SANDBOX_UNAVAILABLE through the structured error channel into tool/result. Callers can tell missing isolation from command failure by error code alone — no guessing stderr text. fail-closed here is plain: environment isn’t ready → answer is don’t run; nobody sneaks a naked downgrade.
Source: packages/sandbox/sandbox/src/index.ts lines 131–144 (SandboxUnavailableError), line 124 (SANDBOX_UNAVAILABLE error code), verified on 2026-08-13.
Layer 2’s implementation is shorter than you’d think. fs-sandbox extends the local filesystem backend and only adds a per-call fence before two mutating ops:
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const { mode } = policy
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
}
packages/fs/fs-sandbox/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.The workspace-write branch below (same file lines 133–147) re-normalizes the path before the containment check — specifically to stop the bait-and-switch where check sees A and write follows a symlink to B — then mutates using that checked new target. The writable-root set comes from one function, writableRoots(), same source as Seatbelt’s grant scope for bash, so the two sides don’t drift.
Now Layer 3. Every component that touches mutable state — bash executor, persistent PTY, LSP host, file tools — never talks to the OS directly; they delegate to the ctx.fs and ctx.subprocess sockets. Those sockets share a path namespace by contract: the absolute path from ctx.fs.processPath(target) is openable by a child started via ctx.subprocess (see “Target identity & metadata” in docs/subsystems/filesystem.zh.md). The file read sees and the file bash touches are the same file in the same world.
So swapping worlds becomes swapping sockets. packages/e2b/ ships fs-e2b and subprocess-e2b adapters that implement the same seam via E2B’s Filesystem API and Commands/PTY API. The official README says “zero consumer changes” bluntly:
“Existingdsh-bash-local,dsh-terminal-bash, anddsh-lsp-stdioneed no E2B-specific fork. They delegate every operation in the execution environment toctx.fsandctx.subprocess, so once these two E2B adapters are mounted, all their mutable-state work happens inside the same sandbox.”
The boundary is drawn cleanly too: only files and processes move; the harness process itself, model calls, agent & session state, and session persistence stay local (README line 15). That’s why the Agent feels nothing: it still calls the same tools, tools still wire to the same two sockets — only the world behind the sockets changed.
Grok Build’s sandbox is a standalone Rust crate xai-grok-sandbox, organizing confinement strength by preset profiles — already line-checked on this site: Five sandbox Profiles covers profile tiering, Full auth chain from tool request to confined execution covers how one tool call is released layer by layer. Versus DSH, Grok’s strength is engineering completeness of local confinement; the shared fs/subprocess namespace and two-seam whole-world remote swap abstraction has no equivalent in the Grok Build materials we’ve verified. Based on public evidence; unknowns retained.
Claude Code’s defenses sit mostly before execution: bash commands pass a three-layer permission chain (mode checks, rule matching, optionally an AI classifier — see chapter 7’s breakdown of bashPermissions.ts); macOS can add sandbox-exec as auxiliary confinement. The official eng blog advises running agents in “sandboxed environments” with guardrails (chapter 7 line 350 citation) — meaning environment-level isolation is largely left to deployers. Based on the reconstructed-source public evidence, CC has neither DSH’s per-call-policy sandbox seam nor a swappable Execution World abstraction; as a product that tradeoff is fair — if decisions are fine enough, environment can stay with the user.
Walk through two boundary scenarios
Scenario 1: open two sessions in one DSH process — session A read-only, session B workspace-write — each runs a file-writing bash command at the same time. Write the policy each command gets from ctx.sandboxPolicy.resolve() through ctx.sandbox.confine(), and explain why the provider needs no state-switching. Scenario 2: a landlock-run-wrapped command exits 125. List the evidence you must check before concluding (hint: LAUNCHER_FAILURE_EXIT convention, fatal diagnostic lines, order of stripping harmless notices), and for both “runner failure” and “command itself exited 125”, write what the tool layer should report to the model.