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

Status Fixed / completed
Maintainer reply ✓ Yes — rboyce-ant
Activity 4 comments · opened Nov 23, 2025 · closed Nov 23, 2025
💡 Likely answer: A maintainer (rboyce-ant, contributor) responded on this thread — see the highlighted reply below.

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

4 Comments

github-actions[bot] · 10 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/9575
  2. https://github.com/anthropics/claude-code/issues/4111

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

soleilcot · 10 months ago

Additional Evidence: Workaround Confirms Root Cause

What We Did

Replaced the slow LLM-based prompt hook with a fast bash script (~10ms execution):

Bash Script Hook:

#!/bin/bash
# Reads JSON from stdin, checks AWS CLI read-only patterns
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

# Check for read-only prefixes: list-, describe-, get-, show-, etc.
# Returns: {"ok": true, "hookSpecificOutput": {"decision": {"behavior": "allow"}}}

Hook Configuration:

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "Bash",
        "hooks": [{
          "type": "command",
          "command": "/path/to/aws-readonly-checker.sh"
        }]
      }
    ]
  }
}

Result

✅ Works perfectly - auto-approves aws route53 list-hosted-zones instantly with NO dialog shown

What This Proves

  1. Race condition confirmed: Fast hooks (<10ms) never show dialogs, slow hooks (1-2s) sometimes do
  2. The bug is real: Dialog is added to UI state before awaiting hook results
  3. Workaround exists: Use command hooks instead of prompt hooks (but this defeats the purpose of using LLM intelligence for security decisions)

Why This Matters

The bash script workaround only works for simple pattern matching. The whole point of prompt-based PermissionRequest hooks is to use LLM reasoning for complex security decisions. The race condition makes LLM-based hooks unreliable, forcing users to choose between:

  • ❌ Unreliable LLM hooks (dialogs flash despite approval)
  • ❌ Simple regex matching (misses edge cases, no contextual reasoning)
  • ❌ Overly broad allow rules (reduced security)

None of these are acceptable for production security automation.

Test Results

Bash hook (instant execution):

$ aws route53 list-hosted-zones --profile test
# ✅ Auto-approved, no dialog, <10ms

LLM prompt hook (1-2s execution):

$ aws route53 list-hosted-zones --profile test
# ⚠️ Dialog shown 50% of the time despite hook returning "allow"

This definitively proves the race condition exists and is timing-dependent.

rboyce-ant contributor · 10 months ago

If you want to make a blocking permission decision, you should use the PreToolUse hook instead.

We asynchronously execute the PermissionRequest hook and display the UI to the user while the hook runs. This allows, for example, the hook to send a Slack notification and wait for the user to approve the permission via either the CLI or a Slack response.

github-actions[bot] · 10 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.