Claude Code Hook Blocks Edit Unexpectedly

A PreToolUse hook keeps rejecting Edit calls with no clear reason, including safe edits. Usually exit-code logic, stdin parsing, or matcher scope at fault.

You wrote a PreToolUse hook to block edits to sensitive paths, and now every Edit call comes back denied — even harmless edits to a README. Or the hook returns success in your terminal when you run it manually, yet Claude Code still treats it as a block. PreToolUse hooks gate every matching tool call by exit code: zero allows, non-zero blocks. The hook receives JSON on stdin describing the tool call, and the matcher pattern in settings.json decides which calls it sees. Most “unexpected block” bugs are either an overbroad matcher, an exit-code mistake, or a stdin parse error masquerading as a deliberate deny. The right combination of debug-print and a manual test usually isolates the cause in minutes.

Common causes

Ordered by hit rate, highest first.

1. Hook script returns non-zero exit by accident

Common patterns: set -e plus an if that falls through, an unset variable under set -u, or a final command that returns 1 even on the happy path. Any non-zero exit blocks the tool call.

How to judge: Run the hook script manually with a sample stdin payload and echo $?. If you see anything other than 0 on the success branch, that 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. If your block logic relies on path inspection but the script defaults to “block,” you reject every edit.

How to judge: Read the matcher line in .claude/settings.json or ~/.claude/settings.json. If it is just "matcher": "Edit" and you intended path-specific filtering, the matcher is too wide.

3. Stdin JSON parse fails silently

Hook scripts get the tool-call payload on stdin. If you parse with jq and jq errors, your script may default to non-zero exit and look like a deliberate block.

How to judge: Add jq . > /tmp/hook-input.json at the top of the script. Trigger a tool call, then inspect the file. If it is empty or invalid, parsing failed.

4. Hook is shadowed or duplicated

If both project .claude/settings.json and user ~/.claude/settings.json define a PreToolUse hook for Edit, both can run. Either one blocking will block the call.

How to judge: Search both settings files for hook entries with the same matcher. Disable one and see if blocks stop.

5. Output to stdout misinterpreted as a deny reason

Some hook versions read stdout for a deny message. If your script accidentally prints debug info to stdout (instead of stderr), the CLI may show it as the reason for the block.

How to judge: Look at the block message printed by Claude Code. If it contains text your script logs, you are leaking debug output to stdout.

6. Hook runtime is missing on a fresh shell

If the hook uses python3 or node and the launcher shell does not have them on PATH, the hook fails before it even reads stdin. Claude Code interprets the failure as a block.

How to judge: Run which python3 in the same shell Claude Code uses. If empty, you found it.

Before you start

  • Have a backup of your current settings.json before editing.
  • Be ready to temporarily disable the hook to isolate the issue.
  • Use a throwaway Edit (touch a comment in a scratch file) for testing.
  • Have jq installed for inspecting stdin payloads.

Information to collect

  • Claude Code version: claude --version.
  • The exact hook script contents and its file path.
  • The matcher entry from settings.json.
  • The deny message Claude Code prints when blocking.
  • A sample stdin payload (capture with the trick in Step 3 below).
  • Output of claude --debug at the moment of the block.

Step-by-step fix

Step 1: Reproduce in isolation

Make a minimal Edit attempt on a scratch file. Confirm the block fires. Note the exact deny message.

Step 2: Temporarily disable the hook

Comment out the hook entry in settings.json and restart Claude Code. Re-run the same Edit. If it now succeeds, the hook is definitely the culprit (vs. permissions or other config).

Step 3: Capture the stdin payload

Add a debug line at the top of your hook script:

#!/usr/bin/env bash
tee /tmp/hook-input.json | jq . > /dev/null

Re-enable the hook, trigger an Edit, then inspect /tmp/hook-input.json. You should see the tool name, path, and 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: $?"

If the manual exit is non-zero on a payload that should pass, you have isolated the logic bug.

Step 5: Tighten the matcher

If you only want to block edits under secrets/, do path filtering in the matcher or early in the script:

{
  "matcher": "Edit",
  "hooks": [
    { "type": "command", "command": "~/.claude/hooks/block-secrets.sh" }
  ]
}
#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path' < /dev/stdin)
case "$path" in
  */secrets/*) echo "blocked: path under secrets/" >&2; exit 2 ;;
  *) exit 0 ;;
esac

exit 0 on the default branch is critical.

Step 6: Separate stdout from stderr

Send debug logs to stderr only:

echo "checking $path" >&2

Leave stdout for the official deny-reason channel if your CLI version uses it.

Step 7: Pin the runtime

If the hook uses python3 or node, use an absolute path:

#!/usr/bin/env bash
/usr/bin/env python3 ~/.claude/hooks/block.py

Or include a PATH export at the top.

Verify

  • A safe Edit on a non-sensitive file goes through with no prompt.
  • A deliberately sensitive Edit (under your protected path) is blocked, with the expected message.
  • The deny message is the one your script emits, not a Claude Code generic.
  • Restarting Claude Code does not regress the behavior.

Long-term prevention

  • Always exit 0 explicitly on the success branch; never rely on implicit exit.
  • Add a one-line test harness that pipes sample payloads through every hook script in CI.
  • Keep matchers as narrow as possible; do path filtering in the matcher itself when supported.
  • Send all debug to stderr; never to stdout.
  • Version-control your ~/.claude/hooks/ directory and settings.json so a regression is easy to bisect.

Common pitfalls

  • Treating set -e as a substitute for explicit exit codes. It is not — you can still leak a 1.
  • Writing one giant hook that matches * and tries to switch by tool name inside; smaller specific hooks are easier to debug.
  • Forgetting that hooks run in a fresh non-interactive shell with a minimal PATH.
  • Putting print statements that go to stdout and then wondering why the deny message changed.
  • Running the hook script as root manually and missing perms-related bugs that hit in the real (user) context.

FAQ

  • What exit code allows the tool call? Zero. Non-zero blocks.
  • Can I see what Claude Code sends to the hook? Yes, pipe stdin to a file with tee. The JSON payload includes tool_name and tool_input.
  • Where does the deny message come from? Depends on Claude Code version. Some read stderr, some read a specific JSON response from stdout. Check the docs for your version.
  • Can I block based on diff content, not just path? Yes — the payload includes the proposed change. Parse it with jq and decide.
  • Do hooks run for every tool, or just the ones I match? Only those matching the matcher pattern. Use specific matchers for performance.
  • Why does my hook work in terminal but fail when Claude Code runs it? Almost always PATH or missing interpreter. Use absolute paths and a clean shebang.

Tags: #Claude Code #hooks #Troubleshooting #Debug