PreToolUse if:-gated agent hooks fire nondeterministically on non-matching Bash commands, and agent ALLOW verdicts sometimes convert to denies
Summary
Claude Code (verified on 2.1.204) type: "agent" PreToolUse hooks with anif: string-match condition on Bash commands exhibit two related defects:
- The
if:condition fires nondeterministically on Bash commands that do
not match the pattern at all.
- When the dispatched hook agent explicitly outputs an "allow" verdict, the
harness sometimes still converts it into a deny.
Environment
- Claude Code version: 2.1.204
- Hook type:
PreToolUse, matcher"Bash", threetype: "agent"hooks
gated by if: "Bash(git commit*)" (×2) and if: "Bash(git push*)" (×1)
- Observed over 46 real working sessions, 2026-06-07 through 2026-07-08
Configuration (redacted excerpt)
{
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "agent",
"prompt": "Run the project code-reviewer subagent against the staged diff. Output ONLY a single JSON object as the very last line: APPROVE/WARN -> permissionDecision allow with summary; ERROR/BLOCK -> permissionDecision deny with bulleted reason list.",
"if": "Bash(git commit*)",
"timeout": 180
},
{
"type": "agent",
"prompt": "GUARD: check the Bash command that triggered this hook. If it does NOT start with 'git push' ... immediately output {\"permissionDecision\": \"allow\", \"reason\": \"IV2: not-a-git-push\"} as the very last line and stop. ... [subagent dispatch + verdict relay logic follows]",
"if": "Bash(git push*)",
"timeout": 300
}
]
}
]
}
Defect 1 — if: condition matching is nondeterministic
Documentation states hook if: conditions are matched deterministically
against the tool call before the hook agent is ever spawned. Observed
behavior contradicts this: commands that do not start with git commit orgit push at all still cause the corresponding hook agent to be dispatched,
and (per Defect 2) are sometimes denied outright despite being unrelated to
the gated command.
Census methodology: scanned 46 session JSONL transcripts for Bashtool_use entries paired with a subsequent is_error tool_result
containing the literal string "Agent hook condition was not met" (the
harness's own deny message when a hook agent's output doesn't parse as an
allow). Classified each denial as LEGIT if the denied command matches^git\s+(commit|push)\b, else MISFIRE.
Results:
- 14,706 total Bash tool calls scanned
- 319 total agent-hook denials (2.2% of all Bash calls)
- 286 MISFIRE (89.7% of denials) / 33 LEGIT (10.3%)
- Timeframe: 2026-06-07 → 2026-07-08 (32 days)
- Stall time per denial: median 57s, max 221s; cumulative misfire stall time
≈ 4.70 hours
- Breakdown by which hook fired: the
git commit*-gated code-review hook
fired on 232/319 denials, the git push*-gated implementation-verifier
hook on 66/319, the git commit*-gated i18n-check hook on 21/319
Concrete repro commands (redacted)
None of these contain git commit or git push as a substring anywhere,
yet each triggered a git commit*- or git push*-gated agent hook and was
denied:
1.
``bash``
TMP=$(mktemp) || { echo "mktemp failed"; exit 1; }
trap 'rm -f "$TMP"' EXIT
gh api "repos/<owner>/<repo>/issues/<N>/sub_issues" --jq 'length' > "$TMP"
2.
``bash``
for STRAT in poisson_dixon_coles zero_inflated_poisson skellam histogram; do
echo "example line for $STRAT"
done
3.
``bash`
gh issue view <N> --json labels --jq '.labels[].name' > /tmp/start-labels.txt
git commit
This exact command shape was observed misfiring the -gatedgit push
hook on one occasion and the -gated hook on a separateif:` patterns across independent invocations.
occasion — i.e. the same non-matching command tripped two different
4.
``bash``
docker compose exec -T backend python 2>/dev/null <<'EOF2'
<python heredoc body>
EOF2
Retrying the exact same command verbatim typically does not re-trigger
the misfire (per-call misfire probability appears low but non-zero) — though
back-to-back double-denials of the identical command have also been observed
(two consecutive denials of the same command roughly a minute apart).
Defect 2 — hook-agent ALLOW verdicts get converted to denies
One of the three hooks includes an explicit GUARD clause instructing the
agent: *"If the Bash command does NOT start with 'git push' ... immediately
output {"permissionDecision": "allow", "reason": "IV2: not-a-git-push"} as
the very last line and stop."* This is a direct mitigation attempt for
Defect 1.
Despite the hook agent visibly producing that allow JSON (confirmed via a
live in-session reproduction and in ≥8 historical sessions), the harness'stool_result still reads:
Agent hook condition was not met: <the hook agent's own allow-reasoning text, verbatim>
...and the underlying Bash command is denied. This means prompt-level
"guard and allow early" mitigations cannot fix Defect 1 — they only shorten
how much reasoning the agent does before hitting the same conversion bug.
Suspected mechanism (unconfirmed against source): Claude Code's hook
docs describe the expected output shape ashookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason },
while these hook prompts (written before this was discovered) instruct the
agent to emit a bare {"permissionDecision": ...} object without thehookSpecificOutput wrapper. If the harness requires the wrapper and fails
closed (denies) on any output it can't parse into that exact shape, an agent
emitting a bare object — even with a clear "allow" value — would always be
converted to a deny. This has not been verified against Claude Code's
source; filing it in case it's the root cause, or in case it points at a
different one.
Impact
This has cost an estimated ~4.7 cumulative hours of stalled/denied commands
in one project's working sessions over 32 days, and forces a choice between
abandoning if:-gated type: "agent" PreToolUse hooks (moving equivalent
logic to plain git hooks) or tolerating frequent multi-minute stalls plus
occasional false denials of legitimate work.
Expected behavior
if:conditions on PreToolUse hooks should deterministically gate
hook-agent dispatch to only the tool calls that actually match the
pattern, as documented.
- When a
type: "agent"hook's structured output indicates
permissionDecision: allow, the harness should honor that in all cases —
not sometimes convert it to a deny.
Additional notes
- This report intentionally omits the repo name/owner and any proprietary
business logic beyond the minimum needed to reproduce.
- Happy to provide the anonymized scan script and a fuller anonymized data
dump on request.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗