PreToolUse hooks returning exit 2 + deny JSON do not block tool execution
Status Fixed / completed
Reported on v2.1.87
Maintainer reply ✓ Yes — claude[bot]
Workaround ✓ Mentioned in thread ↓
Activity 4 comments · opened Apr 4, 2026 · closed Apr 17, 2026
💡 Likely answer: A maintainer (claude[bot], contributor)
responded on this thread — see the highlighted reply below.
Bug Description
PreToolUse hooks that return permissionDecision: "deny" with exit code 2 are not blocking tool execution. The hooks execute (scripts run), but the platform ignores the deny decision and proceeds with the tool call.
Reproduction
1. Hook script (~/.claude/hooks/git-security-enforcer.sh)
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
[[ -z "$command" ]] && exit 0
# Block git checkout
if echo "$command" | grep -qE "(^|[;&|[:space:]])git[[:space:]]+checkout([[:space:]]|$)"; then
cat >&2 <<MSG
Git checkout blocked.
MSG
cat <<JSON
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "git checkout blocked"
}
}
JSON
exit 2
fi
exit 0
2. Hook registration in ~/.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/git-security-enforcer.sh"
}
]
}
]
}
}
3. Manual verification that the script works
echo '{"tool_name":"Bash","tool_input":{"command":"git checkout some-branch"}}' | bash ~/.claude/hooks/git-security-enforcer.sh
# Output: deny JSON on stdout, error message on stderr, exit code 2
4. Actual behavior in Claude Code
When Claude runs git checkout some-branch via the Bash tool:
- Expected: Tool call blocked, deny message shown
- Actual:
Switched to branch 'some-branch'— checkout succeeds, hook deny ignored
5. Same issue with Edit/Write hooks
A second hook (prevent-main-branch-edits.sh) registered on Write|Edit matcher with the identical deny pattern also fails to block. The Edit tool proceeds and modifies the file.
Environment
- Claude Code version: 2.1.87
- Platform: macOS (Darwin 25.1.0)
- Shell: zsh
- Permission modes tested:
bypassPermissions,acceptEdits— same behavior on both - Other hook types work:
UserPromptSubmithooks fire and inject context correctly.Stophooks fire and block. OnlyPreToolUsedeny decisions are ignored.
Key Observations
UserPromptSubmithooks execute and their output appears in system reminders — workingStophooks execute and block stopping when they return non-zero — workingPreToolUsehooks execute (the script runs, stderr output sometimes visible) butpermissionDecision: "deny"is ignored — broken- The issue is consistent across
Bash,Edit, andWritetool matchers - The issue persists regardless of
defaultModesetting (bypassPermissionsoracceptEdits)
Impact
Users relying on PreToolUse hooks for safety guardrails (blocking destructive git commands, preventing edits to protected files, enforcing worktree isolation) have no actual enforcement. The hooks provide a false sense of security.
4 Comments
For anyone hitting this: it was fixed in v2.1.90 (2026-04-01). The reporter is on v2.1.87.
One gotcha we discovered while investigating: our own Alzheimer guardrails were outputting deny JSON to stderr instead of stdout, and not using the
hookSpecificOutputwrapper. Exit code 2 alone was blocking the call (post-v2.1.90), but the canonical protocol per the resolution of #37210 is: properhookSpecificOutputwithpermissionDecision: "deny"on stdout.We've updated our implementation to use both mechanisms (belt-and-suspenders):
If you're building PreToolUse hooks, use the stdout path — it's more reliable across versions.
Additional reproduction — Edit tool, Python hook, v2.1.89
Same issue confirmed on v2.1.89 (latest stable as of April 9). Our setup uses a Python PreToolUse hook to protect production trading scripts.
Hook behavior (verified independently)
What happens in Claude Code
The hook fires, logs
"status": "blocked"to our audit JSONL, returns exit code 2 — but the Edit tool proceeds and modifies the file. We created a dedicated test file, added it to the protected list, verified the hook returns exit 2 from CLI, then asked Claude to edit it. File was modified.Impact
We had this hook protecting 8 production files (live trading monitors) since March. The protection never actually worked — edits went through silently while the audit log recorded "blocked." We discovered this only because a bug fix edit to a protected file succeeded when it shouldn't have.
The
permissions.denylist insettings.jsondoes enforce blocks on Edit/Write. We're using that as a workaround.Environment
sys.exit(2)on block"Write|Edit|Bash"skipDangerousModePermissionPrompt: trueThis issue was fixed as of version 2.1.90.
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.