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

Status Open
Reported on v2.1.266
Maintainer reply None cached
Activity 0 comments · opened Sep 20, 2026

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:

``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\"}}}'" }
]
}
]
}
}
``

  1. 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.
  2. Headless: same directory, same prompt, run instead:

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

  1. Check /tmp/permreq.log. It stays empty this time. The stream shows:

``json
{"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"`.

  1. 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.

View original on GitHub ↗