[BUG] PermissionRequest Hook Race Condition - Dialog Shows Despite Hook Returning "allow"
[BUG] PermissionRequest Hook Race Condition - Dialog Shows Despite Hook Returning "allow"
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (not multiple bugs)
- [x] I am using the latest version of Claude Code
Summary
PermissionRequest hooks execute asynchronously in parallel with permission dialogs being displayed. When a hook returns {"behavior": "allow"}, the permission dialog is still shown to the user if the hook takes longer than ~1-2 seconds to complete. This creates a race condition where the hook's decision is ignored.
What's Wrong?
The permission system adds dialogs to UI state before awaiting hook results, creating a race between:
- Hook execution completing and removing the dialog from state
- UI rendering the dialog to the user
Result: If the hook is "slow" (>1-2 seconds), the user sees the permission dialog even though the hook already approved the action.
Evidence from Debug Logs
First Command: aws route53 list-hosted-zones (Hook won, no dialog shown)
2025-11-23T04:41:10.042Z [DEBUG] Hooks: Processing prompt hook...
2025-11-23T04:41:11.744Z [DEBUG] Hooks: Model response: {
"ok": true,
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}
2025-11-23T04:41:11.745Z [DEBUG] Hooks: Prompt hook condition was met
2025-11-23T04:41:12.217Z [DEBUG] No hook environment files found
✅ Hook completed in 1.7s → Dialog never shown → Command executed
Second Command: aws route53 list-hosted-zones --profile test (Hook lost, dialog shown)
2025-11-23T04:41:32.382Z [DEBUG] Hooks: Processing prompt hook...
2025-11-23T04:41:34.281Z [DEBUG] Hooks: Model response: {
"ok": true,
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}
2025-11-23T04:41:34.281Z [DEBUG] Hooks: Prompt hook condition was met
2025-11-23T04:41:38.380Z [DEBUG] Getting matching hook commands for Notification with query: permission_prompt
⚠️ Hook completed in 1.9s → Dialog still shown at 04:41:38.380Z (4 seconds after hook approved!) → User sees dialog despite hook approval
Root Cause Analysis
Found in cli.js around lines 2689-2723 (minified code):
case "ask": {
let H = !1;
// 1. Dialog IMMEDIATELY added to UI state
A((U) => [...U, {
assistantMessage: I,
tool: B,
description: C,
input: G,
toolUseContext: Z,
toolUseID: Y,
permissionResult: K,
// ... handlers...
}]); // <-- Dialog created HERE
let E = await Z.getAppState();
// 2. Hook executes ASYNCHRONOUSLY in parallel
(async () => {
for await (let U of HIA([NZ0(B.name, Y, G, Z, E.toolPermissionContext.mode, Z.abortController.signal)])) {
if (H) return;
if (U.permissionRequestResult && U.permissionRequestResult.behavior === "allow") {
H = !0;
// 3. Hook "allow" tries to REMOVE dialog from state
A((q) => q.filter((w) => w.toolUseID !== Y));
W({behavior: "allow", updatedInput: q, userModified: !1,
decisionReason: {type: "hook", hookName: "PermissionRequest"}});
return;
}
}
})();
return; // <-- Returns immediately without waiting!
}
The Problem:
- Line 2689: Dialog pushed to UI state array
- Line 2693: Async IIFE starts hook execution
- Line 2723: Function returns immediately without awaiting
- Line 2699: Hook eventually completes and tries to filter dialog from state
- BUT: If UI rendered the dialog already, user sees it
Expected Behavior
When a PermissionRequest hook returns {"behavior": "allow"}, the permission dialog should never be shown to the user, regardless of hook execution time.
Actual Behavior
- If hook completes in <1.5s: Dialog usually not shown (race won)
- If hook completes in >2s: Dialog shown despite approval (race lost)
- Behavior is non-deterministic and depends on system performance
Steps to Reproduce
- Configure a PermissionRequest hook in
~/.claude/settings.json:
``json``
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should be allowed to execute the requested Bash command in $ARGUMENTS. Auto-approve ONLY safe read-only operations including AWS CLI read commands (list-, describe-, get-, show). If safe to auto-approve, return: {\"ok\": true, \"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}. Otherwise return {\"ok\": true} to show user prompt. NEVER auto-deny."
}
]
}
]
}
}
- Run with debug logging:
claude --debug
- Execute a command not in auto-approved list (e.g.,
aws route53 list-hosted-zones)
- Repeat the same command multiple times
- Observe: Sometimes dialog shows, sometimes it doesn't, even though hook always returns "allow"
- Check debug logs:
grep -A 5 "Hooks: Model response" ~/.claude/debug/latest
- Notice: Hook always approves, but dialog appearance is inconsistent
Related Issues
- #9575: Notification hook only fires ~25% for permission prompts (related symptom)
- This is the root cause explanation for why Notification hooks are unreliable
Impact
- Hook reliability: PermissionRequest hooks cannot be trusted for automated approvals
- User experience: Inconsistent behavior confuses users
- Automation broken: CI/CD workflows with hooks fail unpredictably
- Workaround required: Must add everything to
permissions.allowrules instead of using hooks
Proposed Fix
Before adding dialog to state, await the hook result:
case "ask": {
let E = await Z.getAppState();
// Execute hook FIRST
for await (let U of HIA([NZ0(B.name, Y, G, Z, E.toolPermissionContext.mode, Z.abortController.signal)])) {
if (U.permissionRequestResult && U.permissionRequestResult.behavior === "allow") {
// Hook approved - execute without dialog
W({behavior: "allow", updatedInput: U.permissionRequestResult.updatedInput || G, userModified: !1,
decisionReason: {type: "hook", hookName: "PermissionRequest"}});
return;
}
}
// Hook didn't approve - NOW show dialog
A((U) => [...U, {
assistantMessage: I,
tool: B,
// ... rest of dialog config
}]);
return;
}
Environment
- Claude Code Version: 2.0.50
- Platform: Anthropic API
- Operating System: macOS (Darwin 24.3.0)
- Model: claude-sonnet-4-5-20250929
Additional Information
Hook Configuration:
- Hooks in
~/.claude/settings.json - Using prompt-based PermissionRequest hook
- Hook executes successfully (status logged in debug)
- Hook always returns correct decision
Why This Matters:
The PermissionRequest hook is designed for security automation (e.g., auto-approving safe commands, blocking dangerous ones). The race condition makes it unreliable for this purpose, forcing users to either:
- Manually approve everything (defeats automation)
- Add broad wildcard rules (reduces security)
Neither is acceptable for enterprise/CI environments.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗