SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Defense in Depth: Sandboxing and Four Real Incidents (Part 12)

SL
Aug 14, 2026 10 min read
Defense in Depth: Sandboxing and Four Real Incidents (Part 12)

Series: Inside the DeepSeek Harness — Part 12 of 16


Part 5 introduced the sandbox seam at the interface level: confine(argv, policy), fail-closed, never silent passthrough. This post goes two levels deeper — the actual platform mechanics behind each of the four sandbox backends, and then four real, dated incidents from the repo’s own docs/postmortem/ directory. Two of the four never crashed anything. They shipped as silently wrong behavior, which is a sharper story than a crash, and a more honest one for a series that’s been making a case for how carefully this codebase is built: careful architecture still ships bugs. What matters is how narrow the fixes turn out to be, and what they reveal about the seam contracts underneath.


The sandbox chain, platform by platform

bwrap (Linux, primary): real flags, not a paraphrase — --ro-bind / / --dev /dev --proc /proc --die-with-parent, plus --tmpfs /tmp --bind <workspaceRoot> <workspaceRoot> under workspace-write mode. Selection isn’t a version check; it’s a real, throwaway functional probe (bwrap --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true) actually spawned before bwrap is trusted as the runtime’s choice.

Landlock (Linux, fallback): the genuinely unusual one. The launcher is a self-restrict-then-exec binary — it builds its own Landlock ruleset, applies that restriction to itself, and only then execs the real payload, which inherits the restriction rather than having it imposed externally. Grants are --ro <path> / --rw <path> flag pairs on an allow-list basis — anything not explicitly granted is denied by the Linux kernel’s own Landlock LSM, not by application logic that could have a bug in it. The probe here is functional too: landlock-run --probe builds and enforces a maximal ruleset in a real short-lived child. A kernel with the right syscalls present but refusing to actually enforce anything would pass a naive --version check and fail this one — which is the entire point of probing functionally instead of checking a version string.

phase 1: build ruleset, restrict SELF phase 2: exec() payload inherits the restriction Not externally imposed — the process sandboxes itself, then becomes the thing it's protecting against.

Figure: self-restrict-then-exec — most engineers assume sandboxing is always imposed from outside a process. Landlock here does it from inside, first.

Seatbelt (macOS): builds a literal Apple Sandbox Profile Language string — (version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null")), plus a (allow file-write* (subpath "...")) clause per writable root — passed via sandbox-exec -p <profile>. A real code comment acknowledges that sandbox-exec is Apple-deprecated but still shipped on every current macOS, and that if it ever disappears, the probe is exactly what’s supposed to fail closed rather than silently stop confining anything.

Windows ACL: the most structurally different of the four, and the most honest about its own limits. Two SIDs, two lifetimes: a standing, per-workspace write SID, granted once and cached for the life of the workspace (an exact-ACE cache makes every later grant O(1)), and a random, per-live-session temp-write SID, minted fresh and revoked when that provider disposes. The seam owns the grant lifecycle; the runner only consumes the two SIDs it’s handed.

workspace SID — standing granted once, outlives every session temp SID — per-session, random session A revoked on dispose session B — new SID Only backend that reports permanent 'partial' enforcement — WRITE_RESTRICTED tokens must keep 'Everyone,' and NTFS hard links can alias a granted file outside the workspace. The code says so directly.

Figure: a real, acknowledged platform gap — the codebase reports it honestly rather than overselling Windows confinement.

Every backend speaks two independent stderr dialects, and consumers check them in a specific order: runner failure (the sandbox itself never got the command running — an infrastructure problem, checked first) versus denial (a file operation got blocked — confinement working exactly as intended). Getting this backward would misattribute a broken sandbox as “the task legitimately failed,” which is precisely the bug in the first incident below.


Four incidents, in the order they teach the most

PM-0004: a “no matches” exit code got reported as a sandbox failure

ripgrep finding nothing exits with code 1 — normal, expected. On older-ABI Linux kernels, the Landlock launcher also prints a benign one-line notice before running the child successfully: landlock-run: partial enforcement (older Landlock ABI). The classifier treated any nonzero exit containing the shared landlock-run: substring as launcher failure — full stop, regardless of which exit code, regardless of what else was on the line. A completely successful “no matches found” search got reported as SANDBOX_UNAVAILABLE, and a second bug compounded it: the filesystem-search caller caught that structured error and papered over it with a generic, unhelpful SEARCH_FAILED.

The fix tightened the contract from a bag of substrings into something that actually distinguishes cases: a RunnerFailureRule now requires an exact fatal line at a specific exit code, checked after explicitly excluding known-informational lines by exact match. Why did the existing test suite never catch this? The postmortem’s own answer: unit tests covered clean success, denial diagnostics, and fatal runner prefixes — but fake test providers only ever emitted “no runner line” or “unambiguously fatal,” never the real combination of “benign runner line, followed by a child-controlled nonzero exit.” And real-Landlock end-to-end tests self-skip on kernels without Landlock support, so full-ABI CI machines structurally could never exercise the partial-enforcement path even once.

ripgrep exits 1 (no matches — normal) OLD: substring + nonzero → SANDBOX_UNAVAILABLE FIXED: exit-code-gated, informational line excluded → passes

Figure: an availability bug, not a breach — the sandbox worked fine; the classifier misread its own success message.

PM-0001: export default silently dropped inject, and the real culprit hid behind a more elegant-looking one

The ACP server crashed on connect. packages/acp/acp/src/index.ts had export default apply alongside its normal named exports (name, inject, apply). Cordis’s Loader prefers .default when present — exports = exports.default ?? exports — which resolved to the bare function and quietly discarded its sibling inject array. The plugin loaded with zero injected services, and ctx.agents threw immediately: “cannot get property ‘agents’ without inject.” Not in a request handler — at plugin load time.

Fixing that revealed a second, independent bug: an optional service read (this.ctx.sessionPersistence, deliberately excluded from the plugin’s declared inject so non-persistent demos don’t hang waiting for it) went through a traceable shadow proxy whose fiber walk is ancestor-only. sessionPersistence lived on a sibling branch, not an ancestor — so the walk hit root and threw. The eventual fix was to use ctx.get('sessionPersistence'), a global isolate-keyed lookup, instead of the property proxy for any optionally-read service. Here’s the detail worth remembering: 178 green tests and 100% line coverage caught neither bug, because every existing test mounted the plugin by hand with inject supplied manually — a path that can never exercise unwrapExports, since that function only runs inside the real Loader, never inside a manual ctx.plugin() call. A worthwhile line from the incident’s own writeup: “Trust the trace, not the theory. The elegant shadow explanation was real but was the second bug; the first was a one-line export mistake.” 100% coverage proved the lines ran — not that the feature worked the way it actually ships.

PM-0002: a config expression that “worked” everywhere except at runtime, for months

disabled: !!js someExpression was used to conditionally enable filesystem plugins. Cordis only interpolates !!js inside a plugin’s config field — Entry.disabled reads entry.options.disabled raw and uninterpolated. The raw expression object is truthy in JavaScript, so every filesystem row stayed permanently disabled, silently, with zero YAML parse error, because the syntax was entirely valid — it just wasn’t being evaluated where the author assumed it would be. The bug was made materially worse by the test setup: the snapshot framework accepted any deterministic transcript as passing, including one where every filesystem tool call returned UNKNOWN_TOOL — and a snapshot refresh baked that broken behavior in as the new “expected” output. The fix moved the condition to an explicit filesystem overlay config instead of a metadata-level expression, added a static check rejecting expression nodes outside config, and taught the snapshot tooling to reject UNKNOWN_TOOL in fresh runs before it could ever become a new fixture. This one connects directly back to Part 2: the patch-layer mechanics there are real and correct, but where an expression is allowed to live within a row turns out to be a genuinely subtle rule, and getting it wrong produces zero errors — just silently wrong behavior, for as long as nobody happens to look.

PM-0003: three different facts got treated as one, briefly (a shorter one)

An agent editing the harness’s own web UI theme had no durable, model-visible notion of which URL was actually hosting the user’s live session. It launched bare Vite (which returns HTTP 200 but serves a blank page missing the boot manifest a real host injects), then launched an entirely separate replacement server on a different port and validated that as success — leaving an orphaned process running, while the user’s actual page had already hot-reloaded correctly the whole time. “HTTP 200,” “build succeeded,” and “the right process is serving the right origin” are three different facts that got silently treated as interchangeable. The fix publishes a canonical URL and runtime mode as a logged, model-visible prompt section, and tests now assert against real external observable state — an actual HTTP response, an actual process, the actual session log — never just “did the command exit 0.”


The rules, stated plainly

The repo’s own docs/defensive-patterns.md is short enough to summarize in full, and every rule maps to something above:

  1. Report orthogonal outcomes independently — the ShellRunResult fields from Part 5; a process can time out and exit 0.
  2. Honor public contracts on both sides — normalize every failure representation before it crosses a public boundary, the way Part 10’s LLM layer normalizes both throw and in-band error into one LlmFailure shape.
  3. Async state is not synchronous state — don’t assume causal attribution across a queued, async API.
  4. Dispose must reach quiescence, not just request it — await actual exit before declaring teardown done.
  5. Contain callback exceptions in the dispatcher — one bad listener in a waterfall chain must never break the others.
  6. Never hand untrusted output the ambient environment or predictable paths — spawned commands get their env scrubbed of anything matching *KEY*/*SECRET*/*TOKEN*/*PASSWORD*; temp files use random names and exclusive 0o600 opens.
  7. Unlink link-shaped paths explicitly — check isSymbolicLink() before deleting; a blind recursive delete on Windows can descend through a junction into the real target directory.

None of these are hypothetical. Every one of them maps to a defect that shipped, or nearly did, in this exact codebase.


Coming up in this series

(All 16 parts are live today — no daily drip.)

Part Title What it covers
1 DeepSeek Harness: Inside the Open-Source Claude Code Rival The launch, the comparison, no privileged core, the four Cordis primitives
2 Composing an App From YAML, Not Code Profiles, bundles, five patch layers, boot, live HMR, --dump-config
3 Scope: Why a Live Agent Is the Key of Its Own Registration Shadowing, restriction, lineage vs. scope
4 Tool Execution in DeepSeek Harness: Guards and Approval Pre-execute, monotonic guards, post-execute, approval
16 DeepSeek Harness: Agent Presets as Data, Not Code Agent presets, PTC/Code Mode, the plugin ecosystem, compared to Claude Code’s agent types
5 Capability Seams: Making Bash Swappable for Sandboxed Bash The 3-role pattern end-to-end, across ~85 real seams
6 The Session Log: DeepSeek Harness’s Enforced Invariant The real invariant-checking code, surface projection
7 Persistence and Compaction: Crash-Safe by Construction JSONL/SQLite, torn-tail repair, the compaction lock bracket
8 Waterfalls: The One Event Pattern That Runs Everything Five dispatch modes, durable vs. live events, retry
9 The Agent Loop: Turns, Steps, and a Real Cancellation Bug The phase state machine, the inbox, a dated bug fix
10 The LLM Layer: One Message Format, Every Surface Message vocabulary, streaming, retry-via-log-replay
11 Subagents and Workflows: Composing Agents From Agents Provider kinds, continuable children, Ralph rounds
12 Defense in Depth: Sandboxing and Four Real Incidents (this post) bwrap/Landlock/Seatbelt/ACL, real production postmortems
13 Three Surfaces, One Spine: Web, Typert RPC, and SDK/ACP Typert codegen, the four-quadrant RPC envelope
14 Engineering Rigor: DeepSeek Harness’s Verification Gate The 100% coverage gate, real engineering-culture quotes
15 What a Second Production Harness Teaches dsh vs. the Agent Harness series, side by side

Part 16 shipped after this series’ initial 15 posts, once Agent Presets and Code Mode landed — it reads best right after Part 4, which is why its row sits there instead of at the end.

Next: Part 13 zooms out to how a browser, a typed RPC client, and an external SDK all reach the same running process — three surfaces, one spine.


References

Comments