[BUG] PermissionRequest Hook Race Condition - Dialog Shows Despite Hook Returning "allow"

Resolved 💬 4 comments Opened Nov 23, 2025 by soleilcot Closed Nov 23, 2025

[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:

  1. Hook execution completing and removing the dialog from state
  2. 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:

  1. Line 2689: Dialog pushed to UI state array
  2. Line 2693: Async IIFE starts hook execution
  3. Line 2723: Function returns immediately without awaiting
  4. Line 2699: Hook eventually completes and tries to filter dialog from state
  5. 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

  1. 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."
}
]
}
]
}
}
``

  1. Run with debug logging: claude --debug
  1. Execute a command not in auto-approved list (e.g., aws route53 list-hosted-zones)
  1. Repeat the same command multiple times
  1. Observe: Sometimes dialog shows, sometimes it doesn't, even though hook always returns "allow"
  1. Check debug logs: grep -A 5 "Hooks: Model response" ~/.claude/debug/latest
  1. 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.allow rules 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:

  1. Manually approve everything (defeats automation)
  2. Add broad wildcard rules (reduces security)

Neither is acceptable for enterprise/CI environments.

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗