Sandbox: From seatbelt to the Execution World
ctx.fs and ctx.subprocess share one path namespace, so the whole execution environment can be swapped
THE QUESTION THIS PAGE ANSWERS
ANSWER FIRSTWhat is the key idea behind “Sandbox: From seatbelt to the Execution World”?
ctx.fs and ctx.subprocess share one path namespace, so the whole execution environment can be swapped
Make the claim earn its place. Use this page as a decision aid, not a definition to memorize. Connect the idea to one real task, one observable result, and one failure that would change your mind.
Write one question you could answer with evidence after trying this idea.
A conclusion that sounds complete but leaves the key assumption untested.
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.
The handoffs inside “Interactive demo · Execution-world switchboard”
“Play first, then lecture.” shows that an Agent is not defined by the model alone. Each handoff between model, context, tools, state, permissions, and people affects both progress and recovery.
Write the state before adding capability
Starting from “Up front: DSH splits the sandbox into three layers, each owning its job”, split the workflow into starting state, next action, tool result, state update, and stop condition. Debugging then means finding the first lost piece of information or authority instead of saying vaguely that the model “got worse”.
A happy path is not reliability
Use “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.” to replay one successful and one failed run. Record the context, tool result, and owner at each turn; the workflow is maintainable when a second person can follow it without the original builder.
From “Interactive demo · Execution-world switchboard” to “Which layer draws the sandbox boundary”
“Interactive demo · Execution-world switchboard” grounds the problem in “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 wheth…”. “Which layer draws the sandbox boundary” then moves it toward “Up front: DSH splits the sandbox into three layers, each owning its job”. Together, they show that the lesson is not just a conclusion to remember, but a claim with conditions.
Carry the judgment into the next situation
When analyzing an Agent, trace state, action, tool result, and next step in order. Each handoff should explain where information came from, who confirmed it, and where failure stops.
- “Interactive demo · Execution-world switchboard”: 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 wheth…
- “Which layer draws the sandbox boundary”: Up front: DSH splits the sandbox into three layers, each owning its job
- “The closing point”: The 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…
The final “The closing point” brings the discussion to “The 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…”. The useful thing to carry forward is knowing which judgments must be revisited when input, scale, or risk changes.
I turned one judgment from this article into a small experiment I could run today. Knowing what to observe next is more useful than simply remembering the conclusion.
After reading this, I first looked for the conditions behind the idea instead of copying the method into a project. That order made the later trade-offs much clearer.
When this judgment reaches real work, which constraint should be added first? I am curious which step matters most between reading and the first practical attempt.
No discussion on this article yet.