anthropics / anthropics/claude-code

[BUG] PreToolUse hook `ask` resolves correctly in interactive mode but is silently denied in headless/stream-json mode, because the `PermissionRequest` hook never fires

Open
#95,726 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area:hooks bug has repro platform:linux
Dominant language
TypeScript
Stars
147k
Forks
24k
PR merge metrics
PR metrics pending

Description

Title

[BUG] PreToolUse hook ask resolves correctly in interactive mode but is silently denied in headless/stream-json mode, because the PermissionRequest hook never fires

What's Wrong?

With one fixed .claude/settings.json (a PreToolUse hook on Bash returning permissionDecision: "ask", plus a catch-all PermissionRequest hook on matcher: "" that logs the request and resolves it), the same ask is handled two different ways depending on how the session is driven:

  • Interactive terminal session (claude with a TTY): works exactly as documented. The PermissionRequest hook fires, gets logged, and resolves the request. Transcript shows ⎿ Allowed by PermissionRequest hook.
  • Headless session (claude -p ... --input-format stream-json --output-format stream-json): the tool call is denied immediately with decision_reason_type: "hook". The PermissionRequest hook never fires, and its log file stays empty. Reproduces identically in permissionMode: "default" and permissionMode: "bypassPermissions", so it isn't a bypass-mode carve-out; the ask never reaches any resolution path at all in this mode.

This defeats the documented purpose of PermissionRequest: "When a tool call needs a permission decision." A hook's ask is exactly that case, and headless/SDK callers with no canUseTool callback have no other way to resolve it. PermissionRequest hooks are the documented mechanism for headless permission handling in that situation, and they demonstrably work in interactive mode with the identical config. Headless mode just never invokes it.

What Should Happen?

Headless mode should invoke the PermissionRequest hook the same way interactive mode does. Per the hooks reference, "ask" "prompts the user to confirm," and the PermissionRequest event exists for exactly this: resolving a decision when one is needed. It should not be silently converted to a deny before that hook ever runs.

Steps to Reproduce

Same .claude/settings.json and prompt for both runs. Only the invocation changes.

  1. .claude/settings.json:
    {
      "permissions": { "defaultMode": "bypassPermissions" },
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash",
            "hooks": [
              { "type": "command", "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"test ask from hook\"}}'" }
            ]
          }
        ],
        "PermissionRequest": [
          {
            "matcher": "",
            "hooks": [
              { "type": "command", "command": "cat >> /tmp/permreq.log; echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}'" }
            ]
          }
        ]
      }
    }
    
  2. Control (interactive): from that directory, run claude normally (TTY, no -p) and prompt "Use the Bash tool to run: echo hi." The transcript shows ⎿ Allowed by PermissionRequest hook, and /tmp/permreq.log gets a line.
  3. Headless: same directory, same prompt, run instead:
    claude -p "Use the Bash tool to run: echo hi. Report exactly what the tool returned." \
      --permission-mode bypassPermissions \
      --output-format stream-json --input-format stream-json --verbose \
      <<< '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Use the Bash tool to run: echo hi. Report exactly what the tool returned."}]}}'
    
  4. Check /tmp/permreq.log. It stays empty this time. The stream shows:
    {"type":"system","subtype":"permission_denied","tool_name":"Bash","decision_reason_type":"hook","decision_reason":"test ask from hook", ...}
    
    with tool_result_meta: non_execution_kind: "user-rejected".
  5. Repeat step 3 with --permission-mode default. Identical result: PermissionRequest hook still never fires.

Claude Code Version

2.1.266 (Claude Code)

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux (Debian GNU/Linux 12, bookworm)

Terminal/Shell

Non-interactive/CI environment

Is this a regression?

I don't know

Additional Information

Surfaced through a third-party wrapper that hosts Claude Code sessions via --input-format stream-json --output-format stream-json without a canUseTool callback, relying entirely on a registered PermissionRequest hook (matcher "") as the resolution path for hook-originated ask decisions. That is the pattern the docs describe as the headless alternative to canUseTool. That wrapper's own PermissionRequest hook never received the request either, confirming the gap is in Claude Code's dispatch, not the wrapper's hook.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the .claude/settings.json reproduction and run the interactive and headless stream-json commands to confirm the divergent behavior. Trace the headless permission path from the PreToolUse ask result to PermissionRequest dispatch, comparing it with the interactive path. Done means the PermissionRequest hook fires and can resolve the request in headless mode as it does interactively.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli, developer-experience, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.