Deus Witness

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 hookResult
exit 0, prints nothingno decision; the normal permission flow applies
exit 0, prints a JSON decisionthe decision applies
exit 2call 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:

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:

  1. Catch your own errors and print a JSON deny.
  2. Run the hook as script || exit 2, so a missing file or interpreter becomes a block.
  3. 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.