Original URL: https://getagenttools.com/blog/pretooluse-hook/

# PreToolUse: the hook that reads every command before my agent runs it

3 Sep 2026Marek.

- claude-code
- agent-ops
- guardrails
- agent-security

`PreToolUse` is the Claude Code hook event that fires after the model has written a tool call and before the tool runs. Its handler can deny the call, ask me, or stay silent. Every shell command an agent types in my workspace passes through one 79-line Python script on that event. The script holds a list of shapes: SQL writes, recursive deletes, HTTP mutations, force pushes, container rebuilds. On a hit it prints `"permissionDecision": "ask"` and I get a prompt with the reason. On anything else it prints nothing and the normal permission flow applies.

 

- **What it is.** Per the reference, PreToolUse "runs after Claude creates tool parameters and before processing the tool call". It matches on tool name: `Bash`, `PowerShell`, `Edit`, `Write`, any MCP tool.
- **How it answers.** Exit code 2 blocks. Exit 0 with JSON can return `allow`, `deny`, `ask` or `defer`. Exit 1 blocks nothing, which is the trap.
- **Mine.** Matcher `Bash|PowerShell`, timeout 10 seconds, 13 danger patterns plus one document gate, always `ask`. 16 regression cases, green on 10 August 2026.
- **Why not permission rules.** A `Bash(...)` rule anchored to the start of a command never saw the `DELETE` inside a quoted `ssh` argument. The hook reads the whole string.
- **The limit.** A hook is a list of shapes you thought of. A route you did not think of passes.

 

The flag from the [disable-model-invocation](https://getagenttools.com/blog/disable-model-invocation/) post closes the model's road to a deploy skill. It does nothing about the commands the model can type by hand instead. This hook is the next layer down in my [Claude Code](https://getagenttools.com/tools/claude-code/) setup: it reads the command, whatever produced it. Prompts are suggestions. Hooks are enforcement.

  

## What is a PreToolUse hook in Claude Code?

 

A PreToolUse hook is a handler Claude Code runs before a tool call executes, with the tool's name and arguments as JSON on stdin, and whose answer can stop the call. Mine are all `"type": "command"`, plain scripts that read JSON and print JSON.

 

I keep hooks on six events; this post is about the one that can say no to a tool call. The shape of the entry in `.claude/settings.local.json`:

 

```json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash|PowerShell",
      "hooks": [{
        "type": "command",
        "command": "python",
        "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guardrail_prod.py"],
        "timeout": 10
      }]
    }]
  }
}
```

 

`timeout` is in seconds and defaults to 600 for command hooks; mine also sets `statusMessage`, a "custom spinner message displayed while the hook runs". The matcher names both shells because on Windows without Git Bash the Bash tool is not registered at all: "a hook that matches only `Bash` never fires there".

 

## How does a hook resolve and answer?

 

The reference walks it in five steps: the event fires, the matcher checks the tool name, an optional `if` condition checks the arguments, the handler runs, Claude Code acts on the result. Two of those steps decide whether your script starts at all.

 

A matcher of `"*"`, `""` or nothing fires on every call. Letters, digits, `_`, `-`, spaces, `,` and `|` make an exact list. Anything else is an unanchored JavaScript regex: `Edit.*` also matches `NotebookEdit`, and `mcp__.*__write.*` catches write tools on every connected server, the cheapest gate I know for [an MCP server you do not fully trust](https://getagenttools.com/blog/mcp-server-security/). The optional `if` field takes permission-rule syntax like `Bash(git *)`. The docs call that filter best-effort and, because of it, tell you to enforce a hard allow or deny through the permission system rather than a hook. My script has no `if`: it spawns on every shell call and matches inside.

 

The answer travels back on two channels. Exit 0 with empty stdout is no decision; silence approves nothing. Exit 2 blocks, stderr becomes the reason Claude sees, and JSON cannot undo it: "even a JSON `permissionDecision` of `"allow"` can't override it". Any other exit code with no valid JSON is a non-blocking error and the action proceeds. Exit code 1 does not block: a policy hook that dies with a traceback has waved the command through.

 

For anything richer, exit 0 and print one JSON object. PreToolUse reads `hookSpecificOutput` with `hookEventName`, `permissionDecision` (`allow`, `deny`, `ask`, `defer`) and `permissionDecisionReason`. `defer` counts only in non-interactive [`-p` mode](https://getagenttools.com/blog/claude-p-headless/); in an interactive session Claude Code logs a warning and ignores it. Parallel hooks resolve to the most restrictive answer: `deny`, then `defer`, `ask`, `allow`. A command hook that hits its timeout is canceled and the call proceeds: "don't count on a stalled hook to act as a gate". A timeout is fail-open.

 

## Ask or deny: which one, and when

 

`ask` shows the permission prompt as normal. `deny` cancels the call and feeds the reason to Claude. For `allow` and `ask` the reason is "shown to the user but not Claude". My ask reason includes a paragraph written for the agent. The model never reads it; I do. The field the model reads is `additionalContext`, and that fix is on my list.

 

A hook `deny` holds in every permission mode, "even in `bypassPermissions` mode or with `--dangerously-skip-permissions`". A hook `ask` forces a prompt in auto mode; the classifier can still deny but cannot approve silently. What `ask` does under bypass mode the pages I read do not say and I have not tested.

 

My split: `ask` for things I do on purpose and want to see, `deny` for things an agent should never do alone. Ask is a speed bump. Deny is a wall with one door, and the door is me editing the command.

 

## My production guardrail: 79 lines of Python

 

The script reads the event with `json.load(sys.stdin)`. If parsing fails it exits 0 and prints nothing. It takes `tool_input.command`, or an empty string, and runs it against `(regex, label)` pairs, case-insensitive, `re.search` anywhere in the string. The list, labels shortened:

 

```python
DANGER = [
    (r"\bDROP\s+(TABLE|DATABASE|INDEX|SCHEMA)\b", "DROP (SQL)"),
    (r"\bTRUNCATE\b", "TRUNCATE (SQL)"),
    (r"\bDELETE\s+FROM\b", "DELETE FROM (SQL)"),
    (r"\bUPDATE\s+\w+\s+SET\b", "UPDATE..SET (SQL)"),
    (r"\bINSERT\s+INTO\b", "INSERT INTO (SQL)"),
    (r"\bALTER\s+TABLE\b", "ALTER TABLE (SQL)"),
    (r"\brm\s+-[a-zA-Z]*r", "rm -r"),
    (r"-X\s*(PUT|DELETE|PATCH)\b", "HTTP mutation (curl -X)"),
    (r"--request\s+(PUT|DELETE|PATCH)\b", "HTTP mutation (curl --request)"),
    (r"docker[ -]compose\s+up\b.*(--build|--force-recreate)", "compose rebuild"),
    (r"docker[ -]compose\s+down\b", "compose down"),
    (r"git\s+push\b.*(--force|--force-with-lease|\s-f\b)", "git push --force"),
    (r"git\s+reset\s+--hard\b", "git reset --hard"),
]
```

 

Thirteen shapes, six of them SQL, because the command that scares me most is `docker exec` into a database container on a remote host, with the SQL at the end of a quoted string no start-anchored rule sees.

 

Since 10 August 2026 a second list, `GATE`, holds one pattern: `--print-to-pdf`. A client audit had reached the PDF stage with a correct measurement and a wrong conclusion, and a human caught it, not the process. The PDF is the last moment a script can intervene, so the hook asks whether the document goes to someone outside and points me to the review gate.

 

Hits are deduplicated within each list, the two parts are joined into one reason, the script prints the `ask` object, and it exits 0, always. It never returns `deny`, never rewrites input, never touches a file. I backed the script up before the gate went in and ran 16 regression cases after: the new gate, every old pattern, and safe commands that must stay silent. All green on 10 August 2026.

 

Writing this found a gap. The matcher sends PowerShell calls to the script, but the list is Unix-shaped. `rm -r` is caught on either shell; `Remove-Item -Recurse` is not on the list. It goes on the list, with a test case.

 

## Why not permission rules?

 

Because my allowlist is wide open on purpose: `python:*`, `curl:*`, `ssh *`, `powershell:*`, hundreds of allow lines, since a session that asks every minute is a session I stop reading. So the rules cannot carry the gates, and the gates have to live somewhere else.

 

My note from 4 June 2026 records the case that settled it. A `Bash(...)` rule in `deny` or `ask` matches the start of a command. A `DELETE FROM` inside `ssh host "docker exec db psql -c '...'"` is an argument to `ssh`, not a command the rule sees. The hook receives the entire string. I have not retested that case against the subcommand checking the docs describe today.

 

The layers stack, since a deny rule in settings applies whatever a hook returns. My [CLAUDE.md audit](https://getagenttools.com/blog/claude-md-audit/) found eight gate rules with nothing enforcing them. A sign on the fence is not the fence.

 

## The four narrow hooks: two-step match, one override

 

My global settings hold four more PreToolUse hooks on `Bash`, all from May 2026. Each blocks with exit code 2 and a stderr message, and each has an override, a word in a comment inside the command.

 

1. **Ad platform deploy.** Fires only when the API host, `-X POST` or `-X PUT`, and a path regex for account or ad objects all line up. The override is `ok` or `deploy` in the command.
2. **Immutable creative object.** A regex for a numeric object id in the URL of an in-place edit. The right pattern is recreate and swap the id.
3. **Bot sends to production groups.** A [Telegram bot](https://getagenttools.com/tools/telegram-bot-api/) send is blocked without `--prod` when the regex sees `chat_id=-NNN`, a group id. Tests go to my own chat.
4. **Rate limit on postbacks.** One change per 24 hours on `PUT` to a live tracker's postback endpoint, state in a file under `~/.claude/state/`. One bundled change of four things at once cost a 57 percent drop in attribution; one change a day tells me which change did it.

 

The override is not a security boundary; the model could type the word. It is friction that puts the word in the transcript, on top of the `disable-model-invocation` flag the ad platform skill already carries.

 

## What a PreToolUse hook cannot do

 

Sahil Kathpal made the case against my kind of script on codeongrass.com on 24 April 2026. His example: hooks that block `cat .env` on `Bash` and `Write`. The agent reads `.env` with the `Read` tool, which the hooks do not match, so the contents sit in the context window and no hook fired. Later the agent uses them in a `Bash` command nobody anticipated. "Your hooks were correctly implemented for the vectors you anticipated. The agent simply used a different route." Hooks are a denylist at the tool-call level: you must enumerate every path, the agent needs one you missed. Blast radius, he writes, is "a function of what the agent can read, what it can write, what commands it can execute, and what external services it can reach".

 

I agree, and my PowerShell gap is the same argument in one line. The reference adds routes of its own: files pulled into a prompt with `@` are inserted without any tool call, so no PreToolUse hook fires for them. A mistyped script path "leaves the gate silently disabled".

 

So the hook is one of the [hard production gates](https://getagenttools.com/blog/production-ready-ai-agents/) I keep, serving the action allowlist in Gate 5 and the early half of human approval in Gate 6. The floor under it is a sandbox that refuses hosts and paths outside the working set, secrets the agent never opens, and objects that ship paused. A hook asks about what it can see. It sees a string.

 

## How do I test a hook before I trust it?

 

Feed it sample input by hand first. The docs show the shape: `echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | python guardrail_prod.py`, then check the exit code and stdout. A safe command must print nothing, a dangerous one must print the JSON, and a crash is an exit code you do not want.

 

My lesson from 4 June 2026: I added the hook mid-session, `/hooks` listed it, and it did not fire. A full restart fixed it, as the troubleshooting page says. The `/hooks` menu is read-only; it proves registration, not execution. So the last step is a canary in the live session: `echo "test rm -rf x"` has to produce a prompt. Until the canary asks, production is not protected.

 

## Do Cursor, Copilot, Codex and opencode have a PreToolUse hook?

 

Yes, all four have a hook that runs before a tool executes and can refuse it, checked in each product's own documentation on 30 August 2026. The names differ, the JSON shapes mostly do not, and one of them ignores matchers.

 

| Agent | Pre-execution hook | How it says no |
| --- | --- | --- |
| Claude Code | `PreToolUse` in settings files, matcher by tool name | `hookSpecificOutput.permissionDecision`: `allow`, `deny`, `ask`, `defer`; exit 2 blocks |
| Cursor | `beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile` in `hooks.json` under `.cursor/` or `~/.cursor/` | `permission`: `allow`, `deny` or `ask` |
| VS Code Copilot | `PreToolUse` in `.github/hooks/*.json` | `hookSpecificOutput.permissionDecision`: `allow`, `deny`, `ask`; the docs say VS Code parses matchers but ignores their values |
| Codex | `PreToolUse` in `hooks.json` under `~/.codex/` or `<repo>/.codex/`, or `[hooks]` in `config.toml` | `hookSpecificOutput.permissionDecision: "deny"`, legacy `decision: "block"`, or exit 2; covers Bash, `apply_patch`, MCP and local tools, not hosted tools like web search |
| opencode | `tool.execute.before` in a plugin under `.opencode/plugins/` or `~/.config/opencode/plugins/` | Throw an error to deny; `output.args` can be rewritten first |

 

Codex and VS Code use Claude Code's field names, so a JSON-printing script ports almost unchanged. I did not check Zed, Gemini CLI or Copilot CLI.

 

## The verdict

 

PreToolUse is the one place in Claude Code where my script reads every command before it runs and says ask or no, and a hook `deny` holds in every permission mode. My guardrail is the soft version: 13 shapes, one document gate, always `ask`, 16 cases green, one PowerShell gap found by writing this. The four hooks beside it are the hard version, exit 2 with an override word. None of them bound the damage; that is the sandbox, the paused object and the secret the agent cannot read. A hook sees a string. Give it the whole string, test it with a canary, and do not ask it to be a wall.

 

Sources, all read on 30 August 2026: the [Hooks reference](https://code.claude.com/docs/en/hooks) and [Automate actions with hooks](https://code.claude.com/docs/en/hooks-guide) in Claude Code's official docs; Sahil Kathpal, [Why Claude Code PreToolUse Hooks Can Still Be Bypassed](https://codeongrass.com/blog/claude-code-pretooluse-hooks-bypass-blast-radius/), codeongrass.com, 24 April 2026; the hooks documentation of [Cursor](https://cursor.com/docs/agent/hooks), [VS Code](https://code.visualstudio.com/docs/copilot/customization/hooks) and [Codex](https://learn.chatgpt.com/docs/hooks); and the [opencode plugins page](https://opencode.ai/docs/plugins/). The hook script, its regression set and my notes are my own files.

Put this into practice

[See the tool list](https://getagenttools.com/tools/)[Train your team](https://getagenttools.com/#training)
