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

Open 💬 1 comment Opened Jul 8, 2026 by ahurjui

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 ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗