Quickstart | Concepts | API | Verify | Coverage | Teams | Anchors | Recovery | Human approval in CI | Verifiable audit log | Unattended runs | Approval when unattended | Hooks vs deny | Skip permissions | PreToolUse hook | Deny not working | Headless permissions | MCP human-in-the-loop
Claude Code PreToolUse hook: examples, exit code 2, and "PreToolUse hook error"
A PreToolUse hook is a command Claude Code runs before a tool call, with the call as JSON on stdin. To block the call, exit 2 (your stderr becomes the reason) or exit 0 and print a JSON deny. A PreToolUse:Bash hook error notice that says Failed with non-blocking status code means your hook exited with another code or never started, and the call went ahead anyway. Below: the input, the exit codes, three examples we ran, and the usual causes of the error. Checked against Claude Code 2.1.312 and the official Hooks reference and Hooks guide.
What the hook receives
For a Bash call, stdin carries roughly this (trimmed from the Hooks reference):
{"session_id": "abc123", "cwd": "/home/user/my-project", "permission_mode": "default",
"hook_event_name": "PreToolUse", "tool_name": "Bash",
"tool_input": {"command": "npm test", "description": "Run test suite"},
"tool_use_id": "toolu_01ABC123..."}
For Write, Edit and Read, tool_input.file_path "is always absolute": Claude Code expands ~ and relative paths before the hook runs. MCP calls arrive as mcp__<server>__<tool> and also carry mcp_server.
Exit codes and decisions
| Your hook | Result |
|---|---|
| exit 0, prints nothing | no decision; the normal permission flow applies |
| exit 0, prints a JSON decision | the decision applies |
| exit 2 | call blocked; stderr is the reason Claude sees |
| any other exit code without a valid JSON decision, a crash, a missing script | "non-blocking error": the call goes ahead |
runs past its timeout (600 s by default for a command hook) | output discarded, call goes ahead |
The JSON form, printed on exit 0:
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": "Not on this branch"}}
permissionDecision is allow (skip the prompt), deny, ask (prompt the user) or defer (only honoured in claude -p). With several hooks the strictest wins: deny > defer > ask > allow. A hook's allow does not override a deny rule in settings, and a hook's deny holds in every permission mode, bypassPermissions included. Exit 1 is the trap: the reference warns that it "is a non-blocking error ... even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2."
Registering a hook
In .claude/settings.json (project) or ~/.claude/settings.json (user):
{"hooks": {"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh || exit 2",
"timeout": 10, "onFailure": "block"}]},
{"matcher": "Edit|Write|NotebookEdit", "hooks": [{"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stay-in-project.sh || exit 2",
"timeout": 10, "onFailure": "block"}]},
{"matcher": "*", "hooks": [{"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/log-calls.sh"}]}
]}}
matcher is the tool name: letters, | and , mean exact names; anything else is an unanchored regular expression (Edit.* also matches NotebookEdit). Matchers are case-sensitive. A plugin's hooks refer to their scripts through ${CLAUDE_PLUGIN_ROOT}. Run /hooks to see what is loaded and where each hook comes from.
Three examples
The scripts need jq and chmod +x. We piped sample events through each one on macOS; outputs are noted.
1. Refuse rm -rf (a JSON decision, so the reason reaches Claude):
#!/bin/bash
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq '(^|[;&|[:space:]])rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r[[:space:]]+-f|-f[[:space:]]+-r)'; then
jq -nc '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny", permissionDecisionReason: "rm -rf is blocked by a project hook"}}'
fi
exit 0
It denied rm -rf build, cd x && rm -fr / and rm -r -f a, and let ls -la through. It also denies git rm -rf x, and it does not see find . -delete or a script that deletes files. Text matching is a fence, not a wall.
2. No writes outside the project (the exit 2 form):
#!/bin/bash
in=$(cat)
path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' <<<"$in")
[ -z "$path" ] && exit 0
root=$(cd "$CLAUDE_PROJECT_DIR" && pwd -P)
dir=$(dirname "$path")
while [ ! -d "$dir" ]; do dir=$(dirname "$dir"); done
case "$(cd "$dir" && pwd -P)/" in "$root"/*) exit 0 ;; esac
echo "Write outside the project refused: $path" >&2
exit 2
It resolves the nearest existing directory, so project/../x and symlinked folders count as outside. Result: /etc/hosts and project/../x exit 2, project/new/dir/f exits 0. It does not cover Bash: echo x > /etc/hosts is a Bash call.
3. Log every call, decide nothing:
#!/bin/bash
jq -c '{t: (now | todate), tool: .tool_name, input: .tool_input}' \
>> "$CLAUDE_PROJECT_DIR/.claude/tool-calls.jsonl"
exit 0
One line per call. The agent can edit this file like any other, so it is a debugging log, not an audit trail.
Fixing "PreToolUse hook error"
Test the script by hand first, as the Hooks guide suggests:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh; echo $?
What the notice usually means:
No such file or directoryor exit 127: the hook never ran. Wrong path, or a relative path that breaks when Claude changes directory. Use"$CLAUDE_PROJECT_DIR"/....jq: command not found: installjqor parse with Python or Node.Permission denied:chmod +xthe script.- A JSON parse or validation message: stdout must be exactly one JSON object. A stray
echobefore it breaks it, even on exit 0. Build the JSON withjq -n, not string concatenation. - A slow hook: past its
timeoutit is cancelled and the call goes ahead (withonFailure: "block"the error saystimed outand the call is blocked). Raise the timeout, or make the hook faster. - Nothing at all, and the hook does not fire: check the matcher spelling and run
/hooks. Full detail is in the debug log:claude --debug, then~/.claude/debug/<session-id>.txt.
The point to remember: in every case above except a JSON deny, the call ran.
Make a policy hook fail closed
A broken hook should block, not wave everything through. Three layers, as in the settings above:
- Catch your own errors and print a JSON
deny. - Run the hook as
script || exit 2, so a missing file or interpreter becomes a block. - Add
"onFailure": "block": a crash, timeout or invalid output then blocks the call. It requires Claude Code v2.1.295 or later; older versions ignore the key.
Do you need a hook at all?
Often not. If the rule is a fixed pattern, a deny rule is simpler and cannot crash:
{"permissions": {"deny": ["Bash(rm -rf *)", "Edit(//etc/**)"]}}
In a path rule a single leading / means the settings file's folder; // is the filesystem root. ask rules make Claude Code prompt for a pattern. For what a command does once it runs, the sandbox is the boundary, not a hook. Use a hook when the answer depends on context: the arguments, the branch, the time, a lookup, or a person. The full comparison: Claude Code hooks vs permissions.deny.
One case built-ins do not cover: a call that should wait for a specific person to approve it while nobody is at the terminal. Deus Witness is a PreToolUse hook for that (deus-witness-hook): it holds a call your signed mandate does not cover until you approve it with a passkey, checks the signed receipt before it prints allow, and denies on any error. deus-witness-hook --doctor "$PWD" checks the three fail-closed layers above. Setup: Require approval before Claude Code runs a command unattended. Running without prompts at all: What --dangerously-skip-permissions skips.