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]
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: UserPromptSubmit hooks fire and inject context correctly. Stop hooks fire and block. Only PreToolUse deny decisions are ignored.

Key Observations

  1. UserPromptSubmit hooks execute and their output appears in system reminders — working
  2. Stop hooks execute and block stopping when they return non-zero — working
  3. PreToolUse hooks execute (the script runs, stderr output sometimes visible) but permissionDecision: "deny" is ignored — broken
  4. The issue is consistent across Bash, Edit, and Write tool matchers
  5. The issue persists regardless of defaultMode setting (bypassPermissions or acceptEdits)

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.

View original on GitHub ↗

4 Comments

j-p-c · 6 months ago

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 hookSpecificOutput wrapper. Exit code 2 alone was blocking the call (post-v2.1.90), but the canonical protocol per the resolution of #37210 is: proper hookSpecificOutput with permissionDecision: "deny" on stdout.

We've updated our implementation to use both mechanisms (belt-and-suspenders):

  • Structured deny JSON on stdout (canonical)
  • Exit code 2 (backup)
  • Human-readable error on stderr (shown to model)

If you're building PreToolUse hooks, use the stdout path — it's more reliable across versions.

drm-collab · 6 months ago

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)

$ echo '{"tool_name":"Edit","tool_input":{"file_path":"/path/to/protected_file.py"}}' \
  | python3 agent_audit_hook.py
# stderr: BLOCKED: protected production file: plan_h/symbol_monitor.py
# exit code: 2

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.deny list in settings.json does enforce blocks on Edit/Write. We're using that as a workaround.

Environment

  • Claude Code 2.1.89 (macOS, Apple Silicon)
  • Hook: Python script, sys.exit(2) on block
  • Matcher: "Write|Edit|Bash"
  • skipDangerousModePermissionPrompt: true
claude[bot] contributor · 6 months ago

This issue was fixed as of version 2.1.90.

github-actions[bot] · 5 months ago

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.