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。
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.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 silenceA 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 changepre-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 reviewGuard 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).
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:
/**
* 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
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.
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:
“PreToolUseonly supports a subset:denyandaskwork;allowdoes not pre-approve,deferis unsupported,additionalContextis ignored,updatedInputis 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:
“-pre_tool_usehooks 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.
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.)