Capabilities and security: one boundary doing all the work
This is the most important chapter for anyone weighing adoption. The tool pipeline is the strongest-engineered part of the codebase. The permission and sandboxing model around it is a single real boundary carrying the entire load, with several capabilities living outside it — and the code is unusually honest about saying so.
How a tool is defined and run
A tool is authored one of two ways. First-party tools use defineTool(...)
(packages/core/tools/src/schema.ts:544), which compiles a parameter spec and
validates arguments inside the execute body
(schema.ts:588-592). Anything satisfying the ToolDefinition interface
(packages/core/tools/src/index.ts:222) — MCP tools, dynamic Cordis tools —
is a raw tool. Registration performs three load-time checks (supported output
schema, positive timeout, no shadowing the reserved run_code transport name;
index.ts:1045-1056) but deliberately does not validate description or
parameters — it only snapshots them (index.ts:1256-1266).
Registrations live in scoped layers so a subagent can be handed a restricted
tool set (index.ts:811, :1152). Execution is a well-built pipeline:
arguments are snapshotted to lossless JSON and deep-frozen
(index.ts:1364, :1416); the caller's cancellation signal is re-fused over
any wrapper replacement so a middleware cannot detach cancellation
(index.ts:1536-1544); results are validated against the declared output
schema; and tools/result observers receive a frozen snapshot
(index.ts:1657-1660).
The pipeline's genuine strengths:
- Fail-closed is the house style and it is implemented, not claimed.
Missing approval service → deny (
index.ts:1694-1699); a rogue approval answerer's return is normalized tounavailable(packages/interaction/user-approval/src/index.ts:325); no sandbox runner → the command never runs (packages/sandbox/sandbox-local/src/index.ts:494). - Guards are monotonic by type. A
ToolGuardcan only return a deny reason orundefined— there is no "allow" value (packages/core/tools/src/index.ts:711), so registration order can never resurrect a denied call. - Security-relevant predicates are single-sourced. The Code-Mode collapse
rule the prompt tells the model is literally the same function the
executor denies by (
index.ts:861feeds the prompt,:1325gates execution) — the model can never be told a rule the executor doesn't enforce.
What a "capability seam" is
A seam is a Cordis Service subclass plus a declare module Context
augmentation, owned by an interface-only package, with N implementation
packages registering under the same key
(packages/sandbox/sandbox/src/index.ts:146-176). Consumers read ctx.sandbox
and never name a backend; swapping is composition-only. This is the same
pattern as the plugin model, applied to capabilities: the file sandbox, the
shell, the filesystem, subprocess execution, and the LLM adapter are all seams.
The security model, as one picture
There is exactly one real enforcement boundary: a kernel/OS-level process
sandbox that wraps subprocess argv. It selects a runner by platform —
linux: ['bwrap','landlock'], darwin: ['seatbelt'], win32:
['windows-acl'] (packages/sandbox/sandbox-local/src/index.ts:159-166) — and
fails closed when none is available (:494-502). The seam contract forbids
silent unconfined passthrough (packages/sandbox/sandbox/src/index.ts:154-156).
This part is good.
Everything else is either advisory or absent, and here is the honest inventory:
1. The shell tool has no command-level permission gate at all. Its own
source says so (packages/shell/tool-bash/src/index.ts:6):
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
* sandboxing executors; see docs/architecture.md § Where new behavior goes.
Under the shipped default mode (workspace-write), the model can run any
command with no prompt as long as file writes land in the workspace —
including curl | sh, git push --force, ssh, and arbitrary network egress.
The sandbox vocabulary explicitly excludes the network
(packages/sandbox/sandbox/src/index.ts:26-27: "Network and process
visibility are outside this vocabulary"). The kernel sandbox limits where
files land; it does nothing about what commands do over the network.
2. run_code executes outside the file sandbox entirely. The file fence
(fs-sandbox) covers ctx.fs and the process sandbox covers subprocess argv,
but run_code is a worker thread whose only limits are env, flags, and heap
(packages/code-runtime/code-runtime-worker-thread/src/index.ts:378-387),
evaluating the model's program as an AsyncFunction with the full Node API
reachable. The module claims "bash-equivalent trust" (index.ts:3-4) but that
undersells the gap: shipped bash is kernel-confined; run_code file access is
confined by nothing. It is gated behind one env var (DSH_TOOLS_MODE), and
nothing warns that turning it on widens the file boundary.
3. The web surface has no authentication — loopback-only by construction
instead (see what-is-dsh.html). This is a defensible choice,
honestly labeled, but it means anything that can issue a loopback request with
a loopback Host header owns the machine.
4. The approval channel is absent in most surfaces. The only
approval/request answerers in the repo are the ACP bridge
(packages/acp/acp/src/index.ts:215) and the web host. The headless bundle
mounts neither — so in headless, every ask decision and every sandbox
escalation request deterministically denies. There is no interactive approval
for the one-shot runner.
5. One env var removes both the sandbox and the prompts.
DSH_PERMISSION_MODE=danger-full-access makes the shell executor bypass the
sandbox (packages/shell/bash-sandbox/src/index.ts:91-93), makes the fs fence
return the raw path (packages/fs/fs-sandbox/src/index.ts:129), and the base
bundle sets the approval policy to never in the same mode. A single
environment variable is the difference between confined and wide open.
6. The file fence is advisory, and reads are never fenced. fs-sandbox's
own header is candid (packages/fs/fs-sandbox/src/index.ts:11-18): "a policy
check in TRUSTED code over a MODEL-CONTROLLED path, NOT a kernel boundary"
with an accepted residual TOCTOU. Reads pass through under every mode (:8),
so read/glob/grep can exfiltrate anything the process can read
regardless of permission mode.
The through-line: the security posture is honest but thin. Almost every gap above is documented in a code comment right next to the gap. That honesty is a real strength — but a reader who takes "sandboxed agent" at face value will over-trust it.
The permission-pipeline gaps
Beyond the missing gates, the pipeline itself has ordering and shape problems:
- Arguments reach policy before they are validated.
defineToolvalidates insideexecute(schema.ts:589), which runs aftertools/pre-execute, approval, and guards — so every guard and hook matcher inspects unvalidated model output (packages/hooks/hooks-claude-code/src/index.ts:340passes rawexec.arguments). - Parameter schemas are open by default. The compiler emits no
additionalProperties: false(schema.ts:451-455), so every tool must re-defend by hand;tool-bashdoes this twice (packages/shell/tool-bash/src/index.ts:214-217,:350-352), and a third-party tool that forgets silently accepts unadvertised parameters. - A pre-execute listener can short-circuit later ones. In a composition
that loads both hook dialects, a Claude-dialect
askthe user approves bypasses the Codex-dialect hook that would have denied (hooks-claude-code/src/index.ts:241-243). The docs claim reorder-proof owner policy "remains a registered guard" (docs/tool-execution-pipeline.md:60), but no permission policy anywhere usestools.guard()— the only guard registration in shipped source is a subagent structured-output check (packages/subagent/subagent-in-process-driver/src/structured.ts:109).
The extension capabilities, by maturity
- Subagents — the most-built subsystem (~8,400 lines). Six providers.
In-process providers honor
toolFilter/maxDepth/persona/outputSchema; out-of-process ones honor none and fail-closed reject any request needing them (packages/subagent/subagent-acp/src/index.ts:147). So capability restriction is simply unavailable for external-CLI subagent backends. - Hooks — two dialects (Claude Code, Codex) over a shared protocol, merged
most-restrictive-wins (
packages/hooks/hook-protocol/src/merge.ts:35-42). Several holes are self-declared TODOs: no run-level halt (hooks-claude-code/src/index.ts:189), no session-start gating (:205), no stop-loop guard (:269), and config read once at load so onehooks.jsonapplies to every session in the process (:52,:104).updatedInputandsystemMessageare parsed then discarded (:176,:179). - MCP — thin, opt-in, the lowest-trust surface. 929 lines, stdio +
streamable HTTP only. Not composed by default, and the CLI README says
why (
apps/cli/reference/README.md:80: "each server command is trusted executable code outside the agent sandbox"). The tool-poisoning surface is real: MCPdescriptionandinputSchemago straight into the model's system prompt with no length bound, no sanitization, and no re-validation on re-sync (packages/mcp/mcp-client/src/tools.ts:146-152), so a server that changes its description between listings silently rewrites the agent's instructions. - Skills — a mature loader with no trust boundary.
skill-filesystemscans project/user roots andtool-skillreturns the full skill body as tool content, but the only recognized frontmatter is two booleans (packages/skill/skill-filesystem/src/index.ts:996-997) — noallowed-tools, no capability scoping, no provenance. A skill file is unconditionally trusted prose injected into context; the only mitigation is that skills are not auto-loaded. - Workflows — a worker-thread runner for model-written scripts,
explicitly not a security boundary (
.../realm.ts:4-6: "the vm is not a security boundary"), with a careful lossless-JSON marshaller at the edge.
Things that ship but do not run
docs/tool-catalog.md presents tool-terminal, tool-lsp,
tool-bash-persistent, mcp-client, tool-cordis, and e2b as first-class
tools, but none is in bundle/base or bundle/headless. That is roughly
7,500 lines of terminal (2,306), LSP (2,486), and e2b (2,659) capability with
no default composition — and therefore far less real-world exercise of their
sandbox interactions than bash gets. The largest single unshipped area is
packages/extensions/ (the dynamic-Cordis tool, tool-cordis/src/api-catalog.ts
alone is 4,751 lines), reachable only in the web-app bundle.
The generated docs disagree with the code
The four capability/tool docs are generator-produced, and
docs/capability-seams.md:471 even claims a "completeness guard" — yet they
drift from the code in eight verifiable places. The pattern matters more than
any single item (see weaknesses.html, theme 2): the gates verify
that a doc matches its generator's output, not that the generator is correct.
| Doc says | Code says |
|---|---|
ctx.lsp impl lsp-local (capability-seams.md:466) |
no such package; it is dsh-lsp-stdio, and tool-catalog.md:31 names it correctly — the two generated docs disagree with each other |
ctx.codeRuntime impl code-runtime-worker (:455) |
it is dsh-code-runtime-worker-thread |
ctx.shell impls omit pwsh-sandbox (:448) |
pwsh-sandbox registers ctx.shell and is the shipped Windows executor |
pwsh "mirrors bash minus sandbox controls" (tool-catalog.md:264) |
tool-pwsh registers sandbox_permissions, justification, and calls approveEscalation — straightforwardly false |
rendered bash schema has 5 properties, no escalation fields (tool-catalog.md:178-217) |
the shipped composition mounts bash-sandbox, so the real wire schema carries escalation fields; the catalog renders the unsandboxed variant |
catalog uses sampleOverCapGlobResults: true (tool-catalog.md:27) |
base bundle sets it false |
reorder-proof owner policy is a registered guard (tool-execution-pipeline.md:60) |
zero permission policies use tools.guard() |
ctx.dynamicCordisRunner owns "the vm sandbox" (capability-seams.md:468) |
the implementation refuses the word: "is not containment: host-realm helper functions remain an escape route" (cordis-host-runner/src/sandbox.ts:6-7) |
The two claims in docs/defensive-patterns.md we could check — the env scrub
and the spill-file hardening — are accurate and verified in code
(packages/subprocess/subprocess/src/index.ts:44;
packages/spill/spill-local/src/store.ts:109-113). The hand-written doc holds;
the generated ones drift.