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.
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.
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.
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.
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
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/shell/shell/src/index.ts(ShellExecutor),packages/sandbox/sandbox/src/index.ts(confine()),packages/sandbox/sandbox-local/src/index.ts(platform chain, real bwrap/Seatbelt probes),packages/sandbox/sandbox-policy/src/index.ts docs/capability-seams.md— the generated, CI-verified graph of all ~85 named servicesdocs/subsystems/shell.mdanddocs/subsystems/sandbox.mddocs/defensive-patterns.mdandAGENTS.md(“explicit > implicit at package boundaries”)- Part 1 through Part 4 of this series
Have thoughts on this?
I read every email. If something resonated, felt wrong, or made you think — I'd love to hear from you.
Comments