31 tools· updated sep 2026

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

Marek.

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

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

AgentPre-execution hookHow it says no
Claude CodePreToolUse in settings files, matcher by tool namehookSpecificOutput.permissionDecision: allow, deny, ask, defer; exit 2 blocks
CursorbeforeShellExecution, beforeMCPExecution, beforeReadFile in hooks.json under .cursor/ or ~/.cursor/permission: allow, deny or ask
VS Code CopilotPreToolUse in .github/hooks/*.jsonhookSpecificOutput.permissionDecision: allow, deny, ask; the docs say VS Code parses matchers but ignores their values
CodexPreToolUse in hooks.json under ~/.codex/ or <repo>/.codex/, or [hooks] in config.tomlhookSpecificOutput.permissionDecision: "deny", legacy decision: "block", or exit 2; covers Bash, apply_patch, MCP and local tools, not hosted tools like web search
opencodetool.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.