DeepSeek Harness · Tool System

Tool Execution Pipeline: Three-Stage Cascade & Monotone Guard

A three-stage line from pre-execute to post-execute — Guard can only tighten, never allow. Core source:packages/core/tools/src/index.ts

Course goalAfter this lesson you can explain three things: which three cascade stages a tool call passes in DSH and what each owns; why Guard has no allow in the type system so plugin order cannot overturn a denial; and that a denied call does not vanish — it materializes as a model-visible error result and still finishes the pipeline.
Interactive demo · pipeline run
bash: rm -rf build/
Stage 1 · pre-execute cascade
Vote before entry: allow / deny / ask; listeners reorderable
Guard layer · monotone guard
Only a deny reason or abstain — no allow in the type
Stage 2 · execute cascade
Wrap execution: timeout, retry, metrics
Stage 3 · post-execute cascade
Rewrite before exit: accept / block; swap content or value
Pick a scenario and hit Play, or scroll here to auto-play Scenario A.
Teaching simulation: listener and guard names are course examples; stage order and Guard's monotone semantics map to packages/core/tools/src/index.ts and docs/tool-execution-pipeline.zh.md. Watch one thing while playing: once any step gives a deny reason, nobody later can overturn it.
Mechanism · what each of the three stages owns

State the problem first. Permission checks, human approval, timeouts, result rewriting, UI rendering — all want to hang on the single act of tool execution. If every tool handles it alone, 40 tools mean 40 copies of permission code. DSH makes tool execution a pipeline: policy lives at fixed stations; the tool body does one thing — execute and return a value.

Pipeline order is at line 8 of docs/tool-execution-pipeline.zh.md: tools/pre-execute first, then monotone guards, then tools/execute and tools/post-execute. Waterfall is DSH's listener queue: each listener gets (exec, next) — call next() to pass the decision, or return a decision and settle on the spot.

The three-way split is clear. Stage 1 pre-execute votes before the tool runs — only allow, deny, or ask (human approval). ask continues only with allowed-once from the approval service; without an approval channel it is deny. Stage 2 execute is wrapping: timeout, retry, metrics wrap the real body — it can replace the cancel signal but not the call identity. Stage 3 post-execute checks after the result: accept as-is, swap content, swap value, or block into a corrective error.

Denial is not silence

A denied call materializes as an isError result Error: reason and still walks post-execute and tools/result. The model sees why it was denied — the loop does not stall on one denial.

Parameters cannot change

pre-execute can veto but cannot rewrite parameters. The tool/call event is logged before execution, and the UI pending card already rendered original params — changing them would desync history, UI, and execution (index.ts lines 583–586 type comments spell out this exclusion).

Guard is synchronous final review

Guard runs after every pre-execute vote and before the tool body — a sync function: a string is the deny reason; undefined abstains. Global Guards first, then along the agent's scope chain far to near (index.ts lines 1118–1127).

Core visual · full path of one call
tool/call logged UI renders pending card in sync pre-execute cascade allow / deny / ask ctx.approval approval continue only on allowed-once Monotone Guard deny or abstain — no allow execute cascade timeout / retry / tool body post-execute accept / block deny materializes as Error result skip tool body; still run post-execute after finalizeContent tools/result freezes the final draft
Teaching diagram: path maps to the official flowchart in docs/tool-execution-pipeline.zh.md; node copy is course-adapted.
Guard monotonicity · why the type has no allow

Start with the edge case: two pre-execute listeners — one wants allow, one ask — who wins? Whoever is earlier. Waterfall short-circuits; the first listener that skips next() and returns a decision settles it. So pre-execute is order-sensitive by nature — change plugin load order and the security conclusion may change.

DSH's fix is an order-insensitive final review after pre-execute. Guard's return type has only two shapes: a string (deny reason) or undefined (abstain). No return value can express consent. Register ten Guards or a hundred — however you order them, the conclusion can only get stricter, never looser. The type definition is the evidence:

packages/core/tools/src/index.tslines 703–711
/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * listener and before the tool body. Returning a reason denies the call;
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, listener ordering cannot turn a denial back into permission.
 * @param execution - the identity-protected call after extensible pre-execute policy completed.
 * @returns a final denial reason, or `undefined` to leave the call allowed.
 */
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
Source snapshot note:Based on the local deepseek-harness-master repo; verified against packages/core/tools/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.

That comment line is worth copying: “guards have no allow result, listener ordering cannot turn a denial back into permission.” A malicious plugin wanting to allow a denied call needs no runtime defense — it cannot write that action in the type system. Cleaner than checking allow rights at runtime; the problem class is erased.

Then what happens after denial. The scheduler settles in two steps: only if pre-execute decided allow (including ask that got approval) do Guards vote one by one; pre-execute deny reasons and Guard deny reasons merge into one variable — either side giving a reason materializes the call as Error: reason. The tool body is never touched, but the result still carries the post-result mark into post-execute and final observers.

So audit plugins and context-injection plugins still work under denial — denial is just another ordinary result for the rest of the pipeline. Tool exceptions and UNKNOWN_TOOL take the same normalization path.

Source: settlement and materialization at packages/core/tools/src/index.ts lines 1486–1499; exception and UNKNOWN_TOOL normalization at 1546–1555, verified on 2026-08-13.

Side-by-side · same slot, three answers from three systems

Claude Code spreads permission checks across Tool interface methods: each tool brings checkPermissions, validateInput, isReadOnly; BashTool also chains allowlists and an ML classifier (study/chapters/02-tool-system.md lines 350–376). External extensions use PreToolUse / PostToolUse hooks. Interestingly DSH ships its own CC hooks bridge packages/hooks/hooks-claude-code, hanging CC hooks on DSH's waterfall — the bridge docs casually expose two protocol gaps:

packages/hooks/hooks-claude-code/README.zh.md · line 92 (known limits)
PreToolUse only supports a subset: deny and ask work; allow does not pre-approve, defer is unsupported, additionalContext is ignored, updatedInput is logged + warned but not applied”

Those two limits have another reason: pipeline invariants are in the way. Native CC hooks can allow-preapprove and rewrite tool params with updatedInput; DSH's bridge downgrades the former and only logs the latter — allow power is not lent out in DSH, and params are immutable after tool/call is logged. The same CC hook config can do less under another host — that measures the two protocols' expressiveness. Multiple CC hooks also fold to the strictest (deny over ask over allow), order-independent (README line 49) — the same spirit as monotone Guard.

Grok Build's hooks system (crates/codegen/xai-grok-hooks) has only pre_tool_use as a blocking point; decisions are Allow or Deny (src/result.rs lines 5–10), and the module comment states failure semantics outright:

grok-build-main/crates/codegen/xai-grok-hooks/src/lib.rs · lines 16–17
“- pre_tool_use hooks can deny/allow (blocking); all others are non-blocking
- Fail-open by default: hook failures do not block normal operation”

Fail-open means if the hook itself crashes or times out, the call still proceeds. DSH flips it: a pre-execute listener exception normalizes the call into an error result — better to over-block. Both stances make sense: Grok treats hooks as optional add-ons that must not take down the main flow; DSH treats policy as a formal station — if the station collapses, the call should not pass. Grok's tool registry and read-only semantics are fully covered on-site in ToolKind provides default read-only semantics.

Classroom Exercise
01

Hand-trace a full rm -rf path

The deploy registers two pre-execute listeners (first the CC hooks bridge with an ask rule; then a allowlist plugin that returns allow for rm) and one sandbox Guard (returns a reason for commands writing outside the workspace). The model fires bash: rm -rf /tmp/x. Q1: does the approval dialog appear? Q2: swap the two pre-execute registration order — does the answer change? Q3: does the sandbox Guard's conclusion depend on that order? Why? (Hint: waterfall short-circuit + line 1486's denialReason only asks Guards after allow.)

Takeaway:Three cascade stages each own a slice: vote before entry, wrap execution, rewrite results before exit. Order-sensitive extensions live in the waterfall; order-insensitive veto goes to Guard — Guard's type has no allow, so once denial stands nobody overturns it. Denial is a first-class result: materialize as Error text for the model, and the pipeline still finishes.