PreToolUse if:-gated agent hooks fire nondeterministically on non-matching Bash commands, and agent ALLOW verdicts sometimes convert to denies

Status Fixed / completed
Reported on v2.1.204
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 8, 2026 · closed Aug 17, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Summary

Claude Code (verified on 2.1.204) type: "agent" PreToolUse hooks with an
if: string-match condition on Bash commands exhibit two related defects:

  1. The if: condition fires nondeterministically on Bash commands that do

not match the pattern at all.

  1. 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", three type: "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 or
git 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 Bash
tool_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
`
This exact command shape was observed misfiring the
git commit-gated
hook on one occasion and the
git push
-gated hook on a separate
occasion — i.e. the same non-matching command tripped two different
if:` patterns across independent invocations.

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's
tool_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 as
hookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason },
while these hook prompts (written before this was discovered) instruct the
agent to emit a bare {"permissionDecision": ...} object without the
hookSpecificOutput 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.

View original on GitHub ↗

3 Comments

ahurjui · 3 months ago

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 on
non-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

  • Same if: "Bash(git push*)"-gated type: "agent" PreToolUse hook

described in the original report (timeout: 300).

  • Observed 2026-07-11, during live validation of a fix to the same hook.

Baseline — bare git push fires normally

A bare git push in a fresh session correctly dispatched the gated hook and
ran 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 push segment appended after &&, showed
zero hook latency — no dispatch, no verification, nothing — as if the
if: condition never matched at all:

gh auth status --active 2>&1 | head -3 && git push <remote> <branch>
git status --short --branch && git push <remote> <branch>

(Remote/branch redacted; no secrets or tokens appear in either command. In
both cases the leading subcommand — gh auth status / git status — is
unrelated 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 on
PreToolUse/Bash hooks appears to fail in both directions:

  1. Spurious fires (documented above): the condition fires on commands

that do not match the gated pattern at all.

  1. Silent misses (this update): the condition fails to fire on a

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 expensive
downstream hook during trials). 14/14 structurally-equivalent compound
forms — including replicas of the exact two commands above with the target
swapped to a safe echo marker — correctly fired the matcher, plus one
correct 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 downstream type:"agent" hook
dispatch 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 push pattern live to disambiguate; noting the uncertainty
rather than asserting a specific root cause.

Current mitigation

A session-memory convention only ("never combine auth/status checks with
git push in one Bash call") — a workaround, not an enforcement fix. A
tracked, deterministic pre-filter hook is being built project-side to close
the gap regardless of which layer the actual defect lives in.

bcherny collaborator · 1 month ago

Tried to reproduce on 2.1.233 (Linux, fresh config dir): a PreToolUse hook with matcher Bash and if: "Bash(git commit*)" logging each fire, plus an unconditional hook logging every Bash call as the denominator.

  • 2 sessions × 8 commands (your 6 non-matching: echo hi, ls, git status, pwd, date, git log -1, plus 2 matching git commit --allow-empty): the if: hook fired 4/4 on matching and 0/12 on non-matching commands — no spurious fires.
  • Agent-type hook with the same if: and an "always allow" prompt: 3/3 matching git commit calls 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. The if: 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

github-actions[bot] · 1 month ago

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.

Showing cached comments. Read the full discussion on GitHub ↗