When multiple PreToolUse hooks deny the same tool call, only one denial reason is surfaced to the model

Status Open
Reported on v2.1.218
Maintainer reply None cached
Activity 0 comments · opened Jul 24, 2026

Summary

When two (or more) PreToolUse hooks each return permissionDecision: "deny" for the same tool call, Claude Code correctly blocks the call, but only one hook's permissionDecisionReason is surfaced to the model (and recorded in the session transcript). The other denial reasons are silently dropped, even though all hooks demonstrably execute.

Environment

  • Claude Code 2.1.218 (CLI, headless -p mode; also reproduced interactively)
  • macOS 26.5.2 (arm64)

Repro

Project .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "/tmp/hooks/deny-a.sh" },
          { "type": "command", "command": "/tmp/hooks/deny-b.sh" }
        ]
      }
    ]
  }
}

/tmp/hooks/deny-a.sh (deny-b.sh identical except the reason string and log line):

#!/bin/sh
payload=$(cat)
echo "invoked" >> /tmp/hooks/a.log
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED by hook A"}}\n'
exit 0

Then:

claude -p 'Run exactly this bash command once: echo hello. If blocked, report verbatim every block reason you received.' --allowedTools Bash

Observed

  • The tool call is blocked (correct, deny wins).
  • Both a.log and b.log gain entries — both hooks ran.
  • The model receives only ONE of the two reasons; the transcript likewise contains only that one. Which hook's reason wins did not follow registration order in our tests (the same hook's reason was surfaced regardless of its position in the array), so there appears to be no way to control or predict the surfaced reason.

Why it matters

The model acts on the reason it sees. With layered guards (e.g. a generic destructive-command hook plus a project-specific policy hook that points at the sanctioned alternative workflow), the dropped reason is often the one carrying the actionable remediation. The model then "fixes" the surfaced objection, retries, and gets denied again by the hook whose guidance it never saw — a wasted denial loop that surfacing both reasons up front would avoid. It also under-reports policy for auditing: the transcript suggests one guard fired when several did.

Expected / requested

When multiple matching PreToolUse hooks deny, surface all distinct denial reasons (deduplicated, optionally length-capped) to the model — e.g. concatenated or as a list — rather than exactly one. Enforcement behavior (deny wins) is already correct and should not change.

View original on GitHub ↗