PreToolUse hooks returning exit 2 + deny JSON do not block tool execution
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.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗