[BUG] PreToolUse hook is not invoked after a static ask rule for the same command pattern receives session-level approval

Status Closed — not planned
Reported on v2.1.87
Maintainer reply None cached
Activity 7 comments · opened May 26, 2026 · closed Aug 22, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

PreToolUse hook is not invoked after a static ask rule for the same command pattern receives session-level approval

Expected: Hook should always fire on PreToolUse regardless of static rule state — the hook's permissionDecision should be evaluated independently, especially for deny decisions which must not be suppressible by a prior session approval.

What Should Happen?

Expected: Hook should always fire on PreToolUse regardless of static rule state — the hook's permissionDecision should be evaluated independently, especially for deny decisions which must not be suppressible by a prior session approval.

Actual: Once Bash(docker --host:*) was session-approved via the ask prompt, subsequent docker --host ... rm and docker --host ... rmi commands bypassed the hook entirely (no log entries written, no deny enforced).

Why it matters: A deny from a hook should be unconditional — a session approval of a broad pattern like docker --host:* should not be able to whitelist destructive subcommands that the hook is explicitly denying.

Reproduction: Add a broad ask pattern + a PreToolUse hook that denies a subset of matching commands → approve the ask prompt once → subsequent deny-eligible commands run freely.

Essentially the json configs should be independent of hooks

Error Messages/Logs

Steps to Reproduce

Minimal repro for Claude Code hook bypass bug:

  1. Create a PreToolUse hook (~/.claude/hooks/guard.py):

#!/usr/bin/env python3
import json, sys, shlex

data = json.load(sys.stdin)
command = data.get('tool_input', {}).get('command', '').strip()

try:
tokens = shlex.split(command)
except ValueError:
sys.exit(0)

# Skip global flags to find real subcommand
i = 1
while i < len(tokens) and tokens[i].startswith('-'):
i += 2 if tokens[i] in ('--host', '-H') and i+1 < len(tokens) else 1

subcommand = tokens[i] if i < len(tokens) else None

if subcommand in ('rm', 'rmi'):
print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'deny', 'permissionDecisionReason': 'guard: destructive'}}))

sys.exit(0)

  1. Register it in ~/.claude/settings.json:

{
"hooks": {
"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/guard.py"}]}]
},
"permissions": {
"ask": ["Bash(docker --host:*)"],
"deny": []
}
}

  1. Reproduce:

# Step A — trigger the ask prompt and APPROVE it:
docker --host unix:///var/run/docker.sock ps

# Step B — now try a destructive command:
docker --host unix:///var/run/docker.sock rm <any-stopped-container>

Expected: Hook fires, deny blocks the command.
Actual: Hook is not invoked. Command reaches the Docker daemon.

Observable proof: Add open('/tmp/hook.log', 'a').write(command + '\n') to the hook — no log entry appears for step B.

Claude Model

Sonnet (default)

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.87 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

I think this is a design issue at the end of the day. The claude.md and json ask/deny/allow should not override an independent hook as that is a different layer of safety unless that specific hook is explicitly overriden -

Root cause: When a static ask rule gets session-approved, Claude Code bypasses the PreToolUse hook for all subsequent matching commands. The hook's deny output is never reached and so this is essentially a serious safety/security flaw in how this is processed.

View original on GitHub ↗

5 Comments

github-actions[bot] · 3 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/36286
  2. https://github.com/anthropics/claude-code/issues/33343

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

xg-gh-25 · 3 months ago

The interaction between approval persistence and hook invocation is a nuanced architectural decision in Claude Code's security model.

When a static ask rule grants session-level approval, it effectively short-circuits the approval flow for that command pattern, which means downstream hooks (including PreToolUse) don't receive the usual lifecycle events. This is by design — once approval is cached, the system optimizes for performance over hook granularity.

For use cases requiring hook invocation on every execution (like logging, metrics, or conditional guards), consider:

  1. Hook-based rules: Instead of static ask rules, implement your approval logic directly in PreToolUse hooks. This ensures every invocation passes through your logic, even with session-level caching.
  2. Hybrid approach: Use PostToolUse hooks for audit trails when PreToolUse is bypassed by approval caching — they still fire after execution.
  3. Explicit re-approval: For critical commands, avoid session-level approval entirely by returning approval: "once" from hooks.

The tradeoff is between UX (fewer approval prompts) and hook observability. If your use case demands unconditional hook execution, the hook-based guard pattern is more robust than relying on approval flows to trigger hooks.

---
Exploring autonomous coordination patterns in SwarmAI. Discussion: T-MEM: Memory & State

yurukusa · 3 months ago

Thanks for the clean repro and the precise framing. The deny-from-hook should be unconditional — that is the right design read, and the actual behavior you observed is the wrong one.

For context, this is the latest instance of a 9-month cluster around claude-code's permission matching boundary. Two meta-issues track it: #30519 (71 reactions, "Permissions matching is fundamentally broken — 30+ open issues, no staff engagement, community building workarounds") and #39523 (16 reactions, the 9-month timeline specifically for --dangerously-skip-permissions regressions and partial-bypass). The combined area:permissions label tops 25 issues with roughly 804 cumulative reactions.

Your issue extends the cluster on a new axis that isn't covered by #30519's seven-mode taxonomy. The known modes are: wildcard-vs-compound, dead-rule accumulation from "Always Allow", user/project scope hierarchy break, quote-tracking bypass, deny-rule reorder bypass, colon-vs-space syntax contradiction, and partial bypass-mode coverage. What you've documented is a new mode — session-level approval of a static ask rule silently disables PreToolUse hook invocation for the matched pattern. That's not just a hook visibility regression; as you point out, it means a session-cached approval of Bash(docker --host:*) can effectively whitelist destructive subcommands that a hook is explicitly denying. The hook-layer guarantee — that permissionDecision: deny is the final word — is broken by the optimization that caches approvals at the rule layer.

The other reply on this thread (suggesting "use hook-based rules instead of static ask rules") works around the symptom but doesn't address the design break. If a user has both a static rule and a hook, the documentation should commit to one of two contracts: either hooks always run and override (your expectation), or hooks are best-effort observers whose deny is suppressible by approval caching. Today's behavior matches neither — hooks are documented as guards but operate as best-effort observers, and there is no warning when the cache silently transitions a guard into an observer.

A few things that may help while this is open:

  • The community-side compendium of the cluster (7 modes, 14 highest-impact issues, 3 operator workarounds, 4-hook defense path) is here: The Permission Matching Cluster — A 9-Month Enforcement Gap (1,818 words, MIT, no signup). The fourth hook in the defense path — bypass-mode-effective-verifier.sh — already targets the related Mode 7. Your issue is a clean new mode that we'll add as Mode 8 in the next revision of the tracker.
  • For the immediate workaround: if your guard needs to be unconditional, drop the static ask rule entirely and let the hook handle the prompt itself by returning permissionDecision: ask for the broad pattern and deny for the destructive subset. The hook-only path keeps the deny in the hook layer instead of relying on the static-rule layer to forward to it.
  • The cc-safe-setup repo (MIT, ~964 unique cloners over the past 14 days as of 2026-05-26) tracks this cluster in its public Cluster Tracker as Cluster 6. I'll add your issue to the Mode 8 row and link the repro in the next push.

The security read you ended on — "essentially a serious safety/security flaw in how this is processed" — is the right read. The Mode-5 deny-rule-reorder bypass and your Mode-8 deny-suppression-by-cache are both security findings, not UX ones. Worth labeling as such.

ishita-0301 · 2 months ago

One practical workaround is to decouple enforcement from Claude Code's permission system entirely.
The issue here isn't the deny rule itself, it's that the decision to execute the hook depends on state that's already been cached at the session level. Once a broad pattern is approved, any finer-grained policy implemented inside PreToolUse may never get a chance to evaluate. A runtime policy engine that sits outside the permission resolver avoids that coupling.

For example, FailproofAI (https://github.com/FailproofAI/failproofai) intercepts tool executions independently and evaluates its own policies before allowing them to proceed. That means a broad approval like docker --host:* doesn't implicitly disable downstream checks for something more specific like docker ... rm, because the policy evaluation happens in a separate enforcement layer.

So even without changes to Claude Code repository itself, you can restore the invariant that "every destructive invocation is evaluated against policy" rather than "every permission pattern is evaluated once per session."

BGMLAI · 1 month ago

Session approval should cache an operator decision, not cache the absence of future policy evaluation. The safe precedence is: invoke every enforcement hook, combine verdicts with deny-wins semantics, then consult any cached approval only for the remaining ask decision.

A useful regression is exactly this sequence: approve broad docker --host:*, then issue docker --host ... rm; assert that the hook fires with a new tool-use id and the deny is recorded before dispatch. A PostToolUse audit cannot compensate because execution has already happened. The cached approval and hook lifecycle need separate code paths and separate audit fields.

Showing cached comments. Read the full discussion on GitHub ↗