Permission sits on the path, not in the tool
A tool call passes seven stages before it reaches the tool body. Policy, hooks and sandboxing live there — and the tool itself need not know about them.
Where policy goes
There are two ways for a harness to know whether a tool call may run.
The first: each tool checks for itself. A file tool asks whether this path is inside an allowed root. A shell tool asks whether this command is banned.
The second: no tool checks anything. There is a pipeline, and policy lives on that pipeline.
The first is easy to start and rots. With twenty tools you have twenty copies of the same permission logic, each slightly different. To change policy you edit twenty places. And nothing can answer the question “what does this system currently allow”.
The harness I read chose the second. A call passes seven stages:
| # | Stage | What it does |
|---|---|---|
| 1 | tools/pre-execute | A waterfall that allows, denies, cancels or asks |
| 2 | Monotonic guards | May only reduce permission, never add it |
| 3 | tools/execute | Wrappers around the call: timeout, retry, metrics |
| 4 | projectContent | Builds the content returned to the model |
| 5 | tools/post-execute | Inspects or replaces the result |
| 6 | finalizeContent | Settles the content one last time |
| 7 | tools/result | The final, immutable outcome |
Stages 4 through 7 run after the tool has executed. Only the first three guard the door.
And this matters: approval is not a separate stage. It is a listener on stage 1. Stage 1 behaves like a middleware chain (waterfall) — each listener receives (exec, next) and can either call next() or return a decision to stop the call right there.
A guard can only reduce permission
The design detail I like most in this post is in a guard’s return type.
A guard does not return allow. It returns undefined or a reason string:
/** * A monotonic execution guard evaluated after every `tools/pre-execute` * … * returning `undefined` leaves it unchanged. Because guards have no allow * result, a later guard cannot undo an earlier denial. */guard(guard: ToolGuard): () => voidRead the second clause: because guards have no allow result, a later guard cannot undo an earlier denial.
That is a decision about a type, not about execution order. You do not have to trust that guards run in the right order. Even if they run in the wrong order, the outcome is still safe.
The property has a name: monotonic. It only moves one way.
Arguments may not be rewritten
This is the constraint that made me stop, and I have not seen it in another harness.
A hook can block a call. It can attach a reason. It can attach user-facing detail. But it cannot rewrite the arguments:
Arguments cannot be rewritten because history, audit, UI, and execution must agree.— dsh, docs/subsystems/tools.md
One sentence, four parties. The arguments were logged before policy ran. They were shown to the user in the approval prompt. If a hook edits them in flight, then what was logged, what was displayed and what is about to run are three different things.
Consider a concrete case: a user clicks “allow” after seeing rm -rf ./build. A hook rewrites the argument to rm -rf /. The log recorded the first, the user approved the first, and the system runs the second.
So the rule is: to change arguments, you must deny the call and let the model call again with new ones. The new call goes through the whole pipeline, and is logged as a new call.
Loop detection: remind, do not block
The harness ships a guard specifically for repeated calls. It is worth reading because it deliberately does not block:
- name: '@deepseek-ai/dsh-repeat-tool-reminder' config: thresholds: [3, 5, 8] # remind at the 3rd, 5th and 8th repeat exclude: [todo_write] # never track these tools argumentsPreviewChars: 500 # argument characters shown in the detailed reminderThree thresholds, and all three are reminders. On the third repeat the model gets a short message. On the fifth and eighth it gets the detailed version, naming the tool and the repeated arguments.
The documentation is explicit about choosing a reminder over a block. It suits long autonomous runs where a stuck loop is the failure you want to break with advice. It does not suit cases where repeats are legitimate and must run undisturbed.
And one limit is stated plainly. It detects only exact repeats: same tool, same arguments, regardless of property order. Near-identical variants are not caught.
Bad configuration fails at startup rather than changing behaviour silently. Three cases are rejected: an empty threshold list, a threshold below 2, or a duplicate.
View diagram data
type: flowchart
title: Seven stages of a tool call
desc: Three stages guard the call before running, four handle and format the outcome sent back to the model
nodes:
- id: p1
label: 1. tools/pre-execute
sub: allow / deny / ask
focal: true
- id: p2
label: 2. Monotonic guards
sub: Can only reduce permissions
- id: p3
label: 3. tools/execute
sub: Wrapper that executes tool
- id: p4
label: 4. projectContent
sub: Builds raw model content
- id: p5
label: 5. tools/post-execute
sub: Hook to inspect or replace
- id: p6
label: 6. finalizeContent
sub: Settles content final time
- id: p7
label: 7. tools/result
sub: Immutable log result
focal: true
edges:
- [p1, p2]
- [p2, p3]
- [p3, p4]
- [p4, p5]
- [p5, p6]
- [p6, p7]Two things that do not exist
Reading documentation for something and not finding it is also a finding.
There is no total tool budget. I looked for a limit like “at most N tool calls per turn” and found none. There is a per-tool timeout — dsh-tool-web defaults to 30,000 ms — but no ceiling on a session.
There is no default tool retry. Stage 3 is where you attach a retry, not where one already lives. The documentation describes it as an around-dispatch waterfall for timeout, retry, or metrics — meaning you write it if you need it.
Both are consistent with the decision in an earlier post. The harness does not set a budget for you; it provides the place to set one.
The price
Every call passes seven stages, including the ones that are certainly allowed.
Seven stages means seven chances for a badly written listener to break an ordinary call. And because arguments cannot be rewritten, a listener that spots a bad argument has exactly one move. It must deny, and make the model call again. Slower than fixing it in place.
In exchange: one place to set policy, one place to audit, and a tool body that knows nothing about permission.
Do this now
- Do not let tools check permission themselvesIf each tool decides, you have nowhere to answer a global question. Put policy on the path and keep the tool body clean.
- Give guards a one-way return typeIf a guard has no allow result, ordering stops being a safety question. You delete a class of bug by choosing a type.
- Freeze arguments before policy runsLog and display the arguments first, then ask. Editing them after asking makes history and execution disagree.
- A block must carry a readable reasonA blocked call with no explanation makes the model try again. The denial reason is part of the result, not an added note.
The rule
Policy placed inside each tool is policy you cannot audit. Put it on the path, and the tool body becomes the simplest thing in the system.
A blocked tool call still has to return something readable. If it does not, the model will keep retrying — and you have turned one refusal into a loop.— tusgino
- Seven stages: three guard the call before the tool runs, four handle the result after.
- Approval is a listener on
tools/pre-execute, not a stage of its own. - A guard can only reduce permission — with no allow result, a later guard cannot undo an earlier denial.
- Arguments cannot be rewritten, because history, audit, UI and execution must agree.
- There is no total tool budget and no default retry; the harness provides the place, not the policy.