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.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Update: silent-miss evidence — the opposite failure direction
Follow-up to the report above. This documents evidence of the opposite
direction on the same
if:-matching mechanism: instead of firing onnon-matching commands (the misfires documented above), the matcher can
apparently also fail to fire on a command that legitimately matches the
gated pattern, when that pattern is composed inside a compound Bash command.
Environment
if: "Bash(git push*)"-gatedtype: "agent"PreToolUse hookdescribed in the original report (
timeout: 300).Baseline — bare
git pushfires normallyA bare
git pushin a fresh session correctly dispatched the gated hook andran the full downstream verification for 99.4s of real hook latency
before allowing the push. The gate works as expected on the simple case.
Silent miss — two compound commands with zero hook latency
Two subsequent pushes in the same working session, each composed as a
compound Bash command with a
git pushsegment appended after&&, showedzero hook latency — no dispatch, no verification, nothing — as if the
if:condition never matched at all:(Remote/branch redacted; no secrets or tokens appear in either command. In
both cases the leading subcommand —
gh auth status/git status— isunrelated to
git push, with the actual push appended via&&.)Two directions of failure on the same mechanism
Taken together with the original report,
if:-pattern matching onPreToolUse/Bashhooks appears to fail in both directions:that do not match the gated pattern at all.
command that does contain a matching segment, when that segment is
composed behind a leading unrelated subcommand via
&&.Open discrepancy — not yet resolved
A follow-up feasibility spike in the same project tested the matcher's
compound-command parsing in isolation, using a harmless proxy pattern
(never the live
git push*pattern, to avoid re-triggering the expensivedownstream hook during trials). 14/14 structurally-equivalent compound
forms — including replicas of the exact two commands above with the target
swapped to a safe
echomarker — correctly fired the matcher, plus onecorrect true-negative.
That result is inconsistent with the silent-miss evidence above: if the
compound-command parser reliably recognizes these exact shapes when proxied,
something else in the pipeline — matcher evaluation on the literal
git push*pattern specifically, or the downstreamtype:"agent"hookdispatch itself — may be the actual point of failure rather than the
matcher's compound-command parsing per se. This has not been re-tested with
the literal
git pushpattern live to disambiguate; noting the uncertaintyrather than asserting a specific root cause.
Current mitigation
A session-memory convention only ("never combine auth/status checks with
git pushin one Bash call") — a workaround, not an enforcement fix. Atracked, deterministic pre-filter hook is being built project-side to close
the gap regardless of which layer the actual defect lives in.
Tried to reproduce on 2.1.233 (Linux, fresh config dir): a PreToolUse hook with matcher
Bashandif: "Bash(git commit*)"logging each fire, plus an unconditional hook logging every Bash call as the denominator.echo hi,ls,git status,pwd,date,git log -1, plus 2 matchinggit commit --allow-empty): theif:hook fired 4/4 on matching and 0/12 on non-matching commands — no spurious fires.if:and an "always allow" prompt: 3/3 matchinggit commitcalls were allowed; none were denied.What did fire on a "non-matching" command is documented behavior: a pattern that constrains more than the command name (like
git commit*) deliberately fails open when the command contains$(), backticks, or$VAR(or can't be parsed) —echo $(date)fired the hook, per the matching table at https://code.claude.com/docs/en/hooks#bash-if-matching. That's deterministic on command text, but can look random since it depends on incidental command content. Theif:filter is best-effort by design; use permission rules for hard allow/deny.On allow→deny: an agent hook's verdict is a model run reporting whether the condition is met — "not met" blocks by design (agent hooks are experimental), and we found no path that converts an allow verdict into a deny; a timed-out or output-less agent hook renders no decision rather than denying.
Could you share the exact commands that fired spuriously (did they contain
$(), backticks, or$VAR?) and a session transcript showing a matching command denied despite an allow verdict? Happy to dig further with that.🤖 Generated with Claude Code
We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected? This issue will be closed automatically if there's no activity within 7 days.