[BUG] PreToolUse hook is not invoked after a static ask rule for the same command pattern receives session-level approval
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:
- 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)
- 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": []
}
}
- 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.
Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
Found 2 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
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:
PreToolUsehooks. This ensures every invocation passes through your logic, even with session-level caching.PostToolUsehooks for audit trails whenPreToolUseis bypassed by approval caching — they still fire after execution.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
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-permissionsregressions and partial-bypass). The combinedarea:permissionslabel 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 — thatpermissionDecision: denyis 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:
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.askrule entirely and let the hook handle the prompt itself by returningpermissionDecision: askfor the broad pattern anddenyfor 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 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.
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
PreToolUsemay 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 likedocker ... 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."
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.