SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Capability Seams: Making Bash Swappable for Sandboxed Bash (Part 5)

SL
Aug 14, 2026 6 min read
Capability Seams: Making Bash Swappable for Sandboxed Bash (Part 5)

Series: Inside the DeepSeek Harness — Part 5 of 16


Part 1 introduced the term seam in passing: a swappable capability with three roles. It’s time to actually earn that claim. Count every file in the repo that augments interface Context { ... } — i.e. every named ctx.* service, not just the subset with multiple interchangeable providers — and you land at roughly 85: ctx.shell, ctx.fs, ctx.sandbox, ctx.llm, ctx.subagents, ctx.workflowEngine, ctx.jobs, ctx.terminals, ctx.web, ctx.goals, and around 75 more. (The generated, CI-verified docs/capability-seams.md graph — the narrower set with documented multi-provider swappability — lists 56; both numbers are real, they’re just answering slightly different questions.) Every one of them follows the same three-role shape this post walks through in full, using ctx.shell and ctx.sandbox as the worked example.


The three roles, and why one alone isn’t a seam

A Service Definition declares the interface: a ctx.<key>, an abstract class (never a plain TypeScript interface — the glossary is explicit about this, because the class carries the actual Cordis service registration, not just a type shape). A Service Provider implements it. A Consumer injects it and calls it — usually a model-facing tool.

The shell seam ctx.shell — ShellExecutor abstract class, Service Definition LocalBashExecutor / SandboxBashExecutor Providers dsh-tool-bash Consumer The sandbox seam ctx.sandbox — SandboxProvider abstract class, Service Definition bwrap / Landlock / Seatbelt / ACL Providers (one platform chain) SandboxBashExecutor Consumer of THIS seam — same class is a Provider of the shell seam The pattern repeats ~85 times. One role alone is never a seam — a seam is the complete capability.

Figure: the same three-role shape, twice — and `SandboxBashExecutor` sits in both diagrams at once, a Provider of one seam and a Consumer of another.

packages/shell/shell/src/index.ts:65 is the real ShellExecutor abstract class, with three abstract methods — resolve, run, start — that every provider must implement. Providers and consumers normally live in separate packages when they evolve independently, but a package can own more than one role when it’s genuinely one concern: dsh-llm owns both its Service Definition and its Consumer side, because there’s no meaningful world where those evolve apart.


Explicit defaults at the boundary: the resolve/spec split

Here’s a small design rule with an outsized effect on how bugs get introduced (or don’t). A request crossing into a provider is not directly usable — it has optional fields, loose types, caller-supplied shortcuts. AGENTS.md states the governing rule directly: “explicit > implicit at package boundaries.” Defaulting is never a hidden ?? default buried inside run(). It’s an explicit resolve(request): Spec method, owned by the implementation, that the caller calls first.

ShellExecRequest optional cwd, timeout, policy .resolve() ShellExecSpec required fields, clamped timeout .run() ShellRunResult rejects only on infra failure

Figure: defaults are a visible, testable step you can call on its own — not a fallback hiding inside execution.

The payoff of splitting resolve from run: you can unit-test “what defaults does this provider apply” completely separately from “does this provider actually execute a command.” A hidden ?? default inside run() gives you neither — you’d have to execute a real command just to observe what timeout got applied.


One outcome, four orthogonal facts

ShellRunResult doesn’t collapse a shell run into a single status. It reports timedOut, aborted, signal, and exitCode as independent fields, not nested inside one another. The reason is concrete: a process can time out and exit 0, if it caught the timeout signal and shut down cleanly on its own. Read only the exit code and you’d misreport a cut-short run as an unqualified success.

timedOut: true aborted: false signal: SIGTERM exitCode: 0 All four true simultaneously — the process trapped its own timeout signal and exited clean. A single "status" enum could never represent this state without losing information.

Figure: independent boolean lanes instead of one collapsed status — a real, documented defensive pattern, not decoration.

timedOut and aborted stay mutually exclusive with each other (first-cause-wins on one fused deadline — a dedicated timeout/deadline library owns that), but neither is exclusive with signal or exitCode. This is exactly defensive pattern #1 from the repo’s own documented list: report orthogonal outcomes independently; never nest one flag inside another’s branch.


The sandbox seam: fail-closed, never silent

confine(argv, policy) (packages/sandbox/sandbox/src/index.ts:175, on the abstract SandboxProvider class declared at :158) takes the exact argv about to be spawned and returns confined argv, or throws SandboxUnavailableError (SANDBOX_UNAVAILABLE). The source JSDoc states the invariant directly: “confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden.” There’s no code path where the sandbox seam quietly gives up and runs the command unconfined anyway.

platform linux: bwrap → Landlock darwin: Seatbelt win32: Windows ACL functional probe a real throwaway spawn, not a version check used or throws The bwrap probe really spawns: bwrap --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true A kernel with the right syscalls but refusing enforcement fails THIS check — a --version check would miss it.

Figure: the chain isn't chosen by asking "is this binary installed" — it's chosen by actually trying to enforce something and watching what happens.

That’s real code, not a paraphrase: sandbox-local/src/index.ts:69 spawns exactly that bwrap command as a throwaway probe before trusting bwrap as the runtime’s choice, and a matching Seatbelt probe sits at :85. A chain of exactly one candidate (darwin, win32) skips the probe — its execution-time refusal is the fail-closed path instead.

Two dialects of failure exist and are kept deliberately distinct: denial (the backend blocked a file operation — confinement is working correctly) and runner failure (the sandbox itself never got the command running — an infrastructure problem). Consumers check runner-failure evidence first, because conflating the two would misattribute a broken sandbox as “the task legitimately failed.” Part 12 covers a real, dated production incident that shipped exactly because these two dialects weren’t distinguished precisely enough on one platform — this seam’s contract was tightened after that bug, not designed perfectly the first time.

One more scoping detail worth knowing before Part 12: SandboxMode (read-only | workspace-write | danger-full-access) governs file effects only — never network or process visibility. danger-full-access doesn’t confine with an empty policy; it never calls ctx.sandbox at all. That’s an early bypass, not “confinement with nothing configured” — a meaningfully different, and more honest, thing to be able to say about what the mode actually does.


The point: one swap, many consumers move together

ctx.subprocess and ctx.fs share one execution world with ctx.shell — pointing all three at a remote sandbox provider moves Bash, the PTY-backed terminal, and the LSP client together, with zero provider forks anywhere in the consumer code. That’s the concrete version of what Part 1 called “no privileged core”: swapping what’s behind a seam is a config-row change, and every consumer of that seam picks up the change without knowing it happened.


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 (this post) 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 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 6 moves from “how a capability is swapped” to “how the model’s entire view of the world is derived” — the session log, and the invariant that’s actually enforced in code, not just documented.


References

Comments