PreToolUse(Bash) hook returning permissionDecision: "defer" causes "[Tool result missing due to internal error]"
Summary
A PreToolUse hook scoped to Bash that returns {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}} causes every matching Bash tool call to fail with [Tool result missing due to internal error]. The command never executes. Non-deferred decisions (allow, deny, ask) work correctly.
This worked previously and broke without any change to the hook. It persists across full machine restart and extension version downgrade, which points at a harness-side regression in resolving the defer decision rather than a hook or environment issue.
Environment
- Claude Code VS Code extension
2.1.159(also reproduced on the prior version via downgrade) - macOS (Darwin arm64)
- Hook configured in
~/.claude/settings.jsonunderhooks.PreToolUse, matcherBash defaultMode: "auto"
Repro
- Add a PreToolUse hook for
Bashthat prints{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}}and exits 0. - Have the agent run any Bash command (e.g.
echo alive). - Tool call fails immediately with
[Tool result missing due to internal error]. The shell never spawns.
Evidence
Extension log (Anthropic.claude-code output channel) at the moment of failure:
[DEBUG] Hook PreToolUse:Bash (PreToolUse) success: {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}}
[DEBUG] Hook PreToolUse (.../git-guard.py) returned permissionDecision: defer
[DEBUG] Hook result has permissionBehavior=defer
The hook is parsed and validated successfully ("Successfully parsed and validated hook JSON output"), then permissionBehavior=defer is recorded -- but resolution dead-ends instead of falling through to the normal permission system.
Workaround
Disabling the hook (or changing defer -> allow) restores Bash. Neither is acceptable long-term: defer is documented as "fall through to the normal permission system," and allow bypasses the user permission allow-list. The hook in question is a git-safety guardrail, so disabling it removes deployment protections.
Expected
permissionDecision: "defer" should fall through to the configured permission mode / allow-list, exactly as documented, and the command should run (or prompt) accordingly.
4 Comments
Still present on the CLI (
claude2.1.173), and it also breaks subagents specifically.Confirming this is alive beyond the VS Code extension, and adding a dimension the original repro doesn't cover: the **permission mode changes where it bites.**
With
defaultMode: "default"(notauto), main-thread Bash resolvesdefercorrectly — it falls through to the normal permission flow and runs. But subagent (Task tool / sidechain) Bash silently drops the result: the agent's transcript (isSidechain: true) ends on atool_usewith no matchingtool_result, the subagent then returns only its preamble (or fabricates an answer) and reportscompleted— a silent failure. The reporter'sdefaultMode: "auto"is what surfaces it on the main thread too.Minimal repro (headless, deterministic):
PreToolUsehook matchingBashthat emits only{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}and exits 0.claude -p 'spawn one subagent that runsecho hiand reports its stdout'tool_usegets no result. Bisecting the hook to emit"allow"(or no output at all) fixes it.Since the docs state that an explicit
"defer"and "exit 0 with no output" are equivalent "no opinion" signals, the divergence between them on the sidechain path looks like the bug.Workaround for hook authors in the meantime: emit no output (exit 0) instead of an explicit
defer.Can verify this issue still exists and I had claude loop through older versions and looks like this never worked as expected. I'm finding the same issue with the AskUserQuestionTool. Full reproduction steps below (written by claude):
---
defernondeterministically feeds the modeltool_result{is_error:true, "[Tool result missing due to internal error]"}instead of suspendingAdding an API-level reproduction that shows why this happens.
When a
PreToolUsehook returnsdefer, Claude sometimes suspends correctly (turn endstool_deferred, no result) — but sometimes runs an extra continuation inference and, in it,fabricates the deferred tool's result as
is_error: truewith the literal text[Tool result missing due to internal error]. The model then (correctly) reports the tool failed.This placeholder is not written to the on-disk transcript — it only appears in the request
Claude sends the API — so you need to capture the wire to see it.
Setup (3 files in an empty dir)
hook.sh— defer Bash, allow everything else:settings.json(point the command at yourhook.shpath):proxy.js— logs/v1/messagesrequest bodies, forwards everything (incl. your auth headers) to Anthropic:Run (repeat a few times — it's nondeterministic)
What you see
/v1/messagesrequest; Claude exits at thetool_use(
stop_reason: tool_deferred). This is the documented behavior."The Bash tool returned an internal error…" The 2nd request reveals why — Claude inserted
this for the deferred tool:
(grep it with:
grep -o '"is_error":true[^}]*' api_log.jsonl)A tool that was intentionally deferred is being handed back to the model as
is_error: true.The model's "the tool failed" response is the only correct reading of that.
Scope (confirmed)
BashandAskUserQuestion.claude-sonnet-4-6narrates the error;claude-opus-4-8instead silently re-calls the tool.defershipped — bisected 2.1.89 (wheredeferwas added) through 2.1.185; all narrate-past at a noisy ~15–65% rate, none reliably clean.default,plan,acceptEdits).Expected
A deferred tool should never receive an
is_errorresult. Either end the turntool_deferred(as the clean runs already do) or supply a neutral "pending/deferred" marker — never
[Tool result missing due to internal error].Confirming this from the TypeScript Agent SDK side —
@anthropic-ai/claude-agent-sdk@0.3.159. Same non-deterministic bug, same synthetic[Tool result missing due to internal error]fed to the model on the losing race.Reliable reproduction of the timing that flips the race: a
PreToolUsehook that returnsdefersynchronously (no awaits) loses the race a majority of the time. Addingawait new Promise(r => setTimeout(r, 200))before returningdeferwins the race deterministically across dozens of runs.The rule seems to be:
defermust be returned AFTER the assistant message's stream has fully closed (i.e., the SDK has receivedmessage_stopfor the message containing the tool_use). If it returns before, the SDK makes a second inference; that second inference sees the syntheticis_error: truetool_result (@gsdatta's proxy captured it above) and the model narrates a plausible "the tool failed" fallback.Minimal repro against the SDK's
query()API (streaming AsyncIterable prompt, one in-process MCP tool):DELAY_MS=0→ non-deterministic; roughly 3-in-5 runs show a fabricatedTEXT: The tool returned an internal error...andterminal_reason: completedDELAY_MS=200→ every run: no fallback text,terminal_reason: tool_deferredAlso reproduced in a production runner (E2B sandbox, streaming AsyncIterable input, an external stdio MCP shim) with two byte-identical inputs producing the two different outcomes.
Not a critique of the fix priority — just wanted to add SDK-side confirmation and the observation about stream-close timing being the specific race variable, since that gives implementers a clear signal for the fix: don't decide "make follow-up call" until
message_stophas landed.Just replying here that the issue is still relevant so that it doesn't get auto-closed.