You wrote a PreToolUse hook to block edits to sensitive paths, and now every Edit comes back denied — even a harmless one-character change to a README. Or the script returns success when you run it by hand, yet Claude Code still treats it as a block.
Fastest fix: a PreToolUse hook only blocks a tool call in two ways, and getting one of them slightly wrong is the usual cause. Either (a) the script exits with code 2 and writes a reason to stderr, or (b) it exits 0 and prints a JSON object on stdout with permissionDecision set to "deny". Any other path — a jq crash, an unset variable, a missing interpreter — exits with code 1 (or something else), which as of June 2026 is a non-blocking error: Claude Code logs it and lets the edit proceed. So if safe edits are being denied, your script is almost certainly hitting its deny branch (exit 2 or a deny JSON) when it shouldn’t — not “crashing into” a block. Run the script by hand against a captured payload and print $? to see which branch fires.
This guide walks the symptom to its cause and gives you a clean, working hook. The exit-code and JSON rules below match the official Claude Code hooks reference.
How PreToolUse blocking actually works (June 2026)
Get this table right and most “mystery block” bugs disappear. A PreToolUse hook receives the tool call as JSON on stdin and signals its decision through exit code plus output:
| Exit code | stdout JSON | Result |
|---|---|---|
0 | none | No decision — normal permission flow continues (edit proceeds) |
0 | permissionDecision: "allow" | Edit runs, skips the usual permission prompt |
0 | permissionDecision: "deny" | Edit is blocked; permissionDecisionReason is shown |
0 | permissionDecision: "ask" | Escalates to the interactive permission dialog |
2 | ignored | Edit is blocked; stderr text is fed back to Claude as the reason |
1 or other | ignored | Non-blocking error — logged, but the edit proceeds |
Two consequences that trip people up:
- A failing hook no longer blocks. Before you assume “my
jqcrashed, so the edit got blocked,” check the exit code. A crash exits1, which is non-blocking. If the edit was actually denied, your script reached an explicitexit 2or printed a"deny"JSON. exit 2and the deny-JSON are different channels. Withexit 2, Claude reads stderr for the reason and ignores stdout. WithpermissionDecision: "deny", you exit0and the reason comes from the JSON on stdout. Mixing them (printing JSON but exiting 2, or exiting 0 with a stderr message and no JSON) is a very common source of “it shows the wrong reason” or “it doesn’t block at all.”
The full stdin payload looks like this (fields per the docs):
{
"session_id": "abc123",
"transcript_path": "/home/you/.claude/projects/.../transcript.jsonl",
"cwd": "/home/you/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "/home/you/my-project/README.md",
"old_string": "...",
"new_string": "..."
}
}
Common causes
Ordered by hit rate, highest first.
1. The script reaches its deny branch on safe edits
This is the most common cause when every edit is denied. Common shapes: a case/if whose default branch exits 2 instead of 0; a path test that matches more than you think (*secrets* also matches my-secrets-guide.md); or a deny-JSON printed unconditionally.
How to judge: run the script by hand with a known-safe payload (Step 4 below) and echo $?. If you see 2, or stdout contains "permissionDecision": "deny" for a file that should be allowed, that branch is the bug.
2. Matcher pattern is wider than you think
A matcher of Edit matches every Edit call, not just ones touching a specific path — matchers filter on tool_name, never on the file path. If your block logic relies on path inspection but the script defaults to deny, you reject everything. Note also: as of June 2026 a matcher of "*" does not reliably fire; use "" (empty string) to match all tools, or list names explicitly like "Edit|Write".
How to judge: read the matcher line in .claude/settings.json or ~/.claude/settings.json. If it is "matcher": "Edit" and you intended path-specific filtering, the matcher is too wide; the filtering has to happen inside the script.
3. Stdin JSON parse fails, then your default is deny
Hook scripts get the payload on stdin. If jq errors (wrong field path, empty input), and your script’s error handling falls through to an exit 2 or a deny JSON, it looks like a deliberate block. (A bare jq failure on its own exits 1 — non-blocking — so this only blocks if your own logic turns the failure into a deny.)
How to judge: add tee /tmp/hook-input.json at the top of the script. Trigger an edit, then inspect the file. If it is empty or the field you read (.tool_input.file_path) is null, parsing is the problem.
4. Hook is shadowed or duplicated
If both project .claude/settings.json and user ~/.claude/settings.json (and possibly .claude/settings.local.json or a plugin’s hooks.json) define a PreToolUse hook for Edit, all of them run. Any one returning a block blocks the call.
How to judge: run /hooks inside Claude Code to list every configured hook and where it came from. Disable one source and see if the block stops.
5. stdout vs stderr crossed wires
If you mean to deny via exit 2, the reason must go to stderr. If you accidentally print debug text or your JSON to stdout while exiting 2, Claude ignores it and you get a generic or wrong-looking reason. Conversely, if you mean to deny via JSON, that JSON must be the only thing on stdout and you must exit 0.
How to judge: look at the block message Claude Code prints. If it contains stray debug text, you are leaking it onto the channel the CLI reads.
6. Hook runtime is missing on a fresh shell
Hooks run in a non-interactive shell with a minimal PATH. If the hook calls python3, node, or even jq and they are not on that PATH, the script dies before reading stdin. As of June 2026 that failure exits non-2, so it is non-blocking — meaning the symptom is usually “my safety hook silently does nothing,” not “everything is blocked.” Worth ruling out either way.
How to judge: run claude --debug, trigger an edit, and look for a hook error line. Or run which python3 jq in a clean shell (env -i bash -c 'which python3 jq'). Empty output points here.
Before you start
- Back up your
settings.jsonbefore editing it. - Be ready to temporarily disable the hook to isolate the issue.
- Use a throwaway Edit (a comment in a scratch file) for testing.
- Have
jqinstalled and on PATH for inspecting payloads.
Information to collect
- Claude Code version:
claude --version(hook behavior has shifted across releases; pin yours). - The exact hook script contents and its file path.
- The matcher entry and which settings file it lives in (use
/hooks). - The deny message Claude Code prints when blocking.
- A sample stdin payload (capture it with Step 3).
claude --debugoutput captured at the moment of the block.
Step-by-step fix
Step 1: Reproduce in isolation
Make a minimal Edit on a scratch file. Confirm the block fires and note the exact deny message verbatim.
Step 2: Temporarily disable the hook
Comment out (or remove) the hook entry in settings.json and restart Claude Code. Re-run the same Edit. If it now succeeds, the hook is the culprit (not permissions or other config). Re-confirm with /hooks that it is gone.
Step 3: Capture the stdin payload
Put a debug tap at the top of your hook script — tee copies stdin to a file while still passing it through:
#!/usr/bin/env bash
tee /tmp/hook-input.json | jq . > /dev/null 2>&1
exec < /tmp/hook-input.json # rewind so the rest of the script can read stdin
Re-enable the hook, trigger an Edit, then inspect /tmp/hook-input.json. You should see tool_name, tool_input.file_path, and the proposed change.
Step 4: Test the script manually with the captured payload
cat /tmp/hook-input.json | bash ~/.claude/hooks/my-hook.sh
echo "exit: $?"
Read the result against the table above. If exit: 2 (or stdout shows a "deny" JSON) on a payload that should pass, you have isolated the logic bug. If exit: 1, the script is crashing — that is non-blocking, so the real block is coming from a different hook (recheck Step 4 / /hooks).
Step 5: Tighten the matcher and the script
Match only the tools you care about, and do the path filtering inside the script. This example denies edits under any secrets/ directory and explicitly allows everything else:
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-secrets.sh" }
]
}
#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path // empty')
case "$path" in
*/secrets/*)
echo "blocked: path under secrets/" >&2
exit 2 ;;
*)
exit 0 ;;
esac
The explicit exit 0 on the default branch is the whole game — without it, an unset variable or a set -e slip can leave you on a non-zero path. (// empty keeps jq from emitting the literal string null when the field is missing.)
If you prefer the structured-JSON channel instead of exit 2, return this and exit 0:
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "blocked: path under secrets/"
}
}'
exit 0
One caveat worth knowing: an open report (issue #37210) describes the permissionDecision: "deny" JSON being ignored for the Edit tool in some versions. If your deny-JSON does not reliably stop Edit calls, fall back to the exit 2 + stderr form, which is the more dependable block path for Edit today.
Step 6: Keep stdout and stderr on the right channels
For the exit 2 path, send the reason and all debug to stderr only:
echo "checking $path" >&2
Leave stdout clean. For the JSON path, the deny JSON must be the only thing on stdout and you must exit 0. Never mix the two.
Step 7: Pin the runtime
If the hook shells out to python3 or node, do not trust PATH. Use an absolute interpreter path or export PATH at the top:
#!/usr/bin/env bash
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
/usr/bin/python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/block.py"
$CLAUDE_PROJECT_DIR is exported to every hook process, so you can reference project-relative scripts reliably from any working directory.
How to confirm it’s fixed
- A safe Edit on a non-sensitive file goes through with no extra prompt.
- A deliberately sensitive Edit (under your protected path) is blocked, with your exact message — not a Claude Code generic.
claude --debugshows the hook firing on both edits, with the expected decision each time.- Restarting Claude Code (and reopening the project) does not regress the behavior.
Long-term prevention
- Always
exit 0explicitly on the success branch; never rely on an implicit exit status. - Remember the rule: only
exit 2(or a"deny"JSON) blocks; every other non-zero is a non-blocking error. Don’t reach forexit 1to block. - Add a tiny test harness that pipes sample payloads through every hook in CI and asserts the exit code.
- Keep matchers narrow (
Edit|Write, not"*"); do path filtering inside the script. - Send
exit 2reasons to stderr; keep deny-JSON alone on stdout. Don’t mix channels. - Version-control
~/.claude/hooks/and your settings files so a regression is easy to bisect.
Common pitfalls
- Treating
set -eas a substitute for explicit exit codes — you can still fall through to a non-zero status that isn’t2. - Assuming a crashing hook blocks the edit. As of June 2026 it does not; it logs and continues.
- Using
"matcher": "*", which may not fire — use""or explicit names. - Writing one giant hook matched on everything and switching by tool name inside; smaller, specific hooks are far easier to debug.
- Forgetting that hooks run in a fresh non-interactive shell with a thin PATH.
- Running the hook as root by hand and missing permission bugs that only hit in the real user context.
FAQ
- Which exit code allows the tool call, and which blocks it? Exit
0allows (unless your stdout JSON saysdeny). Only exit2blocks via stderr. Exit1or any other code is a non-blocking error — the edit still runs. - My hook crashes but the edit went through anyway. Why? That is expected as of June 2026. A crash exits
1, which is non-blocking. To actually block, exit2or print apermissionDecision: "deny"JSON and exit0. - Can I see exactly what Claude Code sends the hook? Yes —
teestdin to a file (Step 3). The payload includestool_nameandtool_input(for Edit,tool_input.file_path). - Where does the deny message come from? From stderr if you exit
2, or frompermissionDecisionReasonin your stdout JSON if you exit0with adenydecision. They are separate channels. - My deny-JSON is being ignored for Edit specifically. Known behavior in some versions (issue #37210). Use the
exit 2+ stderr form for Edit, which blocks reliably. - How do I find every hook that might be blocking? Run
/hooksinside Claude Code. It lists each configured hook and its source file, so you can spot duplicates across project, user, local, and plugin settings. - Why does my hook work in the terminal but fail when Claude Code runs it? Almost always PATH or a missing interpreter in the non-interactive shell. Use absolute paths, export PATH, and confirm with
claude --debug.