DeepSeek Harness · Approvals & Sandbox

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

Course goalAfter this lesson you can answer two questions: which layer the sandbox boundary should sit on, and why 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).
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 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.

Consumers (working components — no code changes) Two sockets (capability seams) · one path namespace ctx.fs resolve / read / write / processPath ctx.subprocess spawn / process group / stdio Local Execution World fs-local + subprocess-local · plus bwrap / Landlock / Seatbelt to confine child processes The /work/app.ts that read sees is the same /work/app.ts bash can cat
bash: line 1: /etc/hosts: Permission denied (exit code 1)
Non-zero exit + stderrExit code alone is never enough
Step 1 · Check runner failure rulesStrip harmless notice lines first, then look for fatal signatures
Step 2 · Check denial dialectMatch only denial phrases this backend can produce
Hit “Play” to walk through automatically, or use the big buttons above to poke around by hand.
Teaching simulation. Scenario A maps to the Execution World contract in 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.
Which layer draws the sandbox boundary

Up front: DSH splits the sandbox into three layers, each owning its job.

Layer 1 · Same-kernel child processes

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.

Layer 2 · In-process tools

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.

Layer 3 · The whole Execution World

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.

Policy rides with the call, not welded onto the provider

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 failure

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. Then denialSignatures: a match means the sandbox worked and blocked an out-of-bounds op. Order must not reverse.

Denial phrases match backend dialect

bwrap’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 full

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

No backend → refuse to run; never run naked

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.

Key evidence · Structured in-process denial

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:

packages/fs/fs-sandbox/src/index.tslines 126–132 excerpt
  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')
    }
Source snapshot note: Based on the local deepseek-harness-master repo; verified against 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.

Execution World: two sockets, move as a whole

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:

packages/e2b/README.zh.md · line 13
“Existing dsh-bash-local, dsh-terminal-bash, and dsh-lsp-stdio need no E2B-specific fork. They delegate every operation in the execution environment to ctx.fs and ctx.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.

Side-by-side · Grok Build & Claude Code
Grok Build: xai-grok-sandbox

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: bets on permission decisions

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.

Classroom Exercise
01

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.

Takeaway: Sandbox has three layers: wrap argv for same-kernel children, structured deny for in-process tools, swap the whole Execution World at the seam. Policy rides per call — multi-session in one process don’t interfere. fs and subprocess share a path namespace, so mounting E2B adapters swaps the world; bash, PTY, LSP change zero lines; the Agent never notices.