---
title: "Permission sits on the path, not in the tool"
description: "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."
pubDate: 2026-09-26
author: tusgino
tags: [ai-engineering, architecture, security, agents]
series:
  id: tu-llm-den-harness
  title: "From LLM to Harness"
  order: 8
cover: /images/covers/mot-tool-call-di-qua-bay-chang.svg
coverAlt: "Seven connected squares, the middle one solid as the tool body, six with gate ticks above"
lang: en
faq:
  - q: "Why not check permission inside each tool?"
    a: "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."
  - q: "Is approval a stage of its own?"
    a: "No. Approval is a listener on the first stage, which is a waterfall that can allow, deny, cancel or ask."
  - q: "Can a guard block a call?"
    a: "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."
  - q: "Can a hook rewrite arguments before the call runs?"
    a: "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."
---

<TLDR>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.</TLDR>

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

```ts title="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**:

<Quote cite="dsh, docs/subsystems/tools.md">Arguments cannot be rewritten because history, audit, UI, and execution must agree.</Quote>

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:

```yaml title="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.

```diagram
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.

<Callout type="warn" title="A blocked call still has to return something readable">
If you block a call and return an empty string or a vague error, the model will not understand what happened and will call again. The denial reason must say why, in language the model can act on. Blocking without explaining turns one refusal into a loop.
</Callout>

## Do this now

<Steps>
<Step title="Do not let tools check permission themselves">If each tool decides, you have nowhere to answer a global question. Put policy on the path and keep the tool body clean.</Step>
<Step title="Give guards a one-way return type">If a guard has no allow result, ordering stops being a safety question. You delete a class of bug by choosing a type.</Step>
<Step title="Freeze arguments before policy runs">Log and display the arguments first, then ask. Editing them after asking makes history and execution disagree.</Step>
<Step title="A block must carry a readable reason">A blocked call with no explanation makes the model try again. The denial reason is part of the result, not an added note.</Step>
</Steps>

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

<Quote cite="tusgino">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.</Quote>

<KeyTakeaways>
- 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.
</KeyTakeaways>
