From LLM to HarnessPart 8/11

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.

Seven connected squares, the middle one solid as the tool body, six with gate ticks above
TL;DR — A tool call passes seven stages before it reaches the tool body. Policy, hooks and sandboxing live there — and the tool body need not know anything 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:

#StageWhat it does
1tools/pre-executeA waterfall that allows, denies, cancels or asks
2Monotonic guardsMay only reduce permission, never add it
3tools/executeWrappers around the call: timeout, retry, metrics
4projectContentBuilds the content returned to the model
5tools/post-executeInspects or replaces the result
6finalizeContentSettles the content one last time
7tools/resultThe 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:

packages/core/tools/src/index.ts
/**
* 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): () => void

Read 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:

repeat-detection guard config, from the plugin README
- 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 reminder

Three 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.

Seven stages of a tool callThree stages guard the call before running, four handle and format the outcome sent back to the model1. tools/pre-executeallow / deny / ask2. Monotonic guardsCan only reducepermissions3. tools/executeWrapper thatexecutes tool4. projectContentBuilds rawmodel content5. tools/post-executeHook to inspector replace6. finalizeContentSettles contentfinal time7. tools/resultImmutablelog result
Figure 1 — Seven stages of a tool call · tusgino
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

  1. 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.
  2. 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.
  3. Freeze arguments before policy runsLog and display the arguments first, then ask. Editing them after asking makes history and execution disagree.
  4. 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.

Frequently asked questions

Why not check permission inside each tool?
Because every tool would decide differently, and you would have nowhere to answer a global question. Put policy on the path and the tool need not know about it.
Is approval a stage of its own?
No. Approval is a listener on the first stage, which is a waterfall that can allow, deny, cancel or ask.
Can a guard block a call?
Yes, but a guard can only reduce permission. It returns a reason to deny, and no guard can force through a call another guard has already blocked.
Can a hook rewrite arguments before the call runs?
No, and that is deliberate. Arguments are already logged and already shown to the user, so rewriting them would make history, audit, UI and execution disagree.
Tú (tusgino)Tú (tusgino)

AI Software Engineer in Da Nang. I write about production system architecture and what each choice costs.