Tool Execution in DeepSeek Harness: Guards and Approval (Part 4)
Series: Inside the DeepSeek Harness — Part 4 of 16
Part 3 ended on a specific promise: a tool that’s been restricted away is “absent from the prompt AND refuses execution” — not a prompt trick, but something enforced further down the stack. This post is that “further down.” Every tool call in dsh, from every agent, passes through the same eight-stage pipeline before its result reaches the model — and the interesting part isn’t the stages themselves, it’s the specific, narrow shape each one is allowed to have.
The full chain
tool/call (logged)
-> tools/pre-execute (waterfall: allow | deny | ask)
-> guards (deny-only, monotonic)
-> tools/execute (waterfall: around-dispatch)
-> tool body
-> tools/post-execute (accept | block{feedback} | rewrite content)
-> finalizeContent
-> tools/result (emit)
tool/result (logged)
Figure: every tool call, every time — the same eight stages, whether it's `bash` or a plugin nobody's heard of yet.
Every one of those waterfall stages is the same Cordis primitive from Part 1 — a listener chain wrapping next(). What makes this pipeline worth its own post isn’t the mechanism; it’s that each stage’s decision type is deliberately narrower than it could be, and each narrowing closes off a specific class of bug.
Guards can only say no
The ToolGuard type is a plain function returning string | undefined. That’s the entire interface — a guard either returns a denial reason, or it returns nothing. There is no allow return value. Not “an allow value that’s discouraged” — it doesn’t exist in the type. This is enforced by TypeScript, not by convention, and the consequence is exactly what it sounds like: no listener, registered at any point in the guard chain, in any order, can turn a denial back into a permission. Guards run global-first, farthest ancestor first, and any one of them can flip the outcome to deny — none of them can flip it back.
Figure: a one-way valve, and it's one-way because the type system makes the other direction unwritable.
Compare that to tools/pre-execute’s decision type, which is a closed three-way union: allow | { deny, reason } | { ask, reason? }. It deliberately excludes input rewriting as an option. A listener that wants to intercept a call can only allow it, block it, or route it to a human — it cannot silently swap the arguments the model asked for. The reason, stated directly in the docs: “arguments are already logged and presented” by the time pre-execute runs — the tool/call event with the model’s actual arguments is already durable. Letting a policy layer rewrite arguments after that point would mean the audit log, the UI, and the thing that actually executes could each observe a different value for what the model “asked for.” One value, agreed on everywhere, is worth more than a convenience feature.
Approval is a separate phase from guards, not a replacement for them
When pre-execute returns ask, that goes through ctx.approval — a single-provider service (real code: packages/interaction/user-approval/src/index.ts:30 for the approval/request waterfall, :192 for ApprovalService) that surfaces a one-shot prompt to a human, over whatever UI happens to be attached: the web dialog, an ACP bridge, an SDK client.
ApprovalOutcome is a closed four-value union: allowed-once | rejected | cancelled | unavailable. What’s notable is that three of the four values are failure modes, and all three fail closed the same way — a missing answerer, a non-owning answerer, a throwing answerer, and a non-conforming response all collapse to the same safe outcome. There’s no ambiguity between “the human said no” and “something went wrong asking the human”; both stop the call.
Figure: three ways to fail closed, one way to proceed — and even proceeding still has to clear the guard chain next.
And critically: allowed-once doesn’t skip the guard chain — it proceeds into it. Approval and guards are independently stacked veto layers, not a single combined check. A human can approve a call that a guard then denies anyway. One more integrity detail worth knowing: approval requests deliberately omit the tool’s arguments. The human correlates via callId to the tool call that was already streamed and logged, instead of receiving a second copy of the arguments in the prompt — specifically so the approval prompt text can never drift out of sync with what’s actually in the durable log.
Token-minted execution: one frozen identity, seen by everyone
Before any policy runs, execute() (packages/core/tools/src/index.ts:1342) delegates to createExecution() (:1364), which does something worth calling out on its own: it takes a lossless snapshot of the call’s arguments, deep-freezes them, and mints an opaque ToolExecutionToken — a Symbol-branded value that identifies this specific execution. Every subsequent stage — guards, tools/execute, the body, tools/post-execute — receives that same frozen identity. No stage can see a different version of “what was called” than any other stage saw.
Cancellation gets a re-check at exactly one seam-crossing point: after tools/pre-execute settles (which may be async — an approval prompt can take real time), the registry re-checks whether the call was cancelled while waiting. If it was cancelled before the body ever started, the failure code is ABORTED_BEFORE_DISPATCH; if the body was already running when cancellation arrived, it’s ABORTED. Two different codes for two genuinely different situations — “never ran” and “was running, then stopped” — because a caller reacting to the failure needs to know which one happened. An already-started body isn’t force-killed; same-process code can’t be terminated safely, so it drains to quiescence instead. (Part 9 covers exactly this mechanism from the loop’s side, including a real bug in how a wake could get dropped during that drain.)
Post-execute: content or value, never both
The last policy stage, tools/post-execute, can do one of three things: accept the result as-is, block it with corrective feedback (the model sees an error instead of the tool’s output), or rewrite the content shown to the model. What it explicitly cannot do is rewrite content and the programmatic value simultaneously — the docs state the rule directly: “content replacement is presentation policy, not confidentiality policy: a listener that must hide the programmatic value blocks or replaces it.” If a listener needs to hide something from the model, it has to actually block the call — there’s no middle path where the model gets a sanitized-looking success while some other consumer of the result still has access to the real value.
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 (this post) | 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 | 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 16 is the newest post in this series, threaded in right here because it leans directly on this post’s pipeline — it covers the newest, most consequential design decision in dsh: agent modes as directories, not classes. From there, Part 5 zooms out from “how one call is policed” to “how bash itself gets swapped for sandboxed bash with zero changes to this pipeline” — the three-role seam pattern, end to end.
References
- Source citations pulled directly from the repo as of commit
47f9438(master, Aug 13, 2026 — same commit cited throughout this series):packages/core/tools/src/index.ts(execute(), guard chain, pre/post-execute waterfalls),packages/interaction/user-approval/src/index.ts(approval/request,ApprovalService),packages/interaction/permission-presets/src/index.ts docs/tool-execution-pipeline.mdanddocs/subsystems/tools.md, deepseek-ai/deepseek-harnessdocs/subsystems/approval.mdanddocs/subsystems/permission-presets.md- Part 1 through Part 3 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