PreToolUse: the hook that reads every command before my agent runs it
- 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,askordefer. Exit 1 blocks nothing, which is the trap. - Mine. Matcher
Bash|PowerShell, timeout 10 seconds, 13 danger patterns plus one document gate, alwaysask. 16 regression cases, green on 10 August 2026. - Why not permission rules. A
Bash(...)rule anchored to the start of a command never saw theDELETEinside a quotedsshargument. 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 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 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:
{
"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. 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; 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:
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 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.
- Ad platform deploy. Fires only when the API host,
-X POSTor-X PUT, and a path regex for account or ad objects all line up. The override isokordeployin the command. - 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.
- Bot sends to production groups. A Telegram bot
send is blocked without
--prodwhen the regex seeschat_id=-NNN, a group id. Tests go to my own chat. - Rate limit on postbacks. One change per 24 hours on
PUTto 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 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 and Automate actions with hooks in Claude Code's official docs; Sahil Kathpal, Why Claude Code PreToolUse Hooks Can Still Be Bypassed, codeongrass.com, 24 April 2026; the hooks documentation of Cursor, VS Code and Codex; and the opencode plugins page. The hook script, its regression set and my notes are my own files.