PreToolUse(Bash) hook returning permissionDecision: "defer" causes "[Tool result missing due to internal error]"

Status Open
Reported on v2.1.159
Maintainer reply None cached
Activity 4 comments · opened Jun 1, 2026

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.json under hooks.PreToolUse, matcher Bash
  • defaultMode: "auto"

Repro

  1. Add a PreToolUse hook for Bash that prints {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "defer"}} and exits 0.
  2. Have the agent run any Bash command (e.g. echo alive).
  3. 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.

View original on GitHub ↗

4 Comments

BartCorremansM · 2 months ago

Still present on the CLI (claude 2.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" (not auto), main-thread Bash resolves defer correctly — 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 a tool_use with no matching tool_result, the subagent then returns only its preamble (or fabricates an answer) and reports completed — a silent failure. The reporter's defaultMode: "auto" is what surfaces it on the main thread too.

Minimal repro (headless, deterministic):

  1. PreToolUse hook matching Bash that emits only {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}} and exits 0.
  2. claude -p 'spawn one subagent that runs echo hi and reports its stdout'
  3. The subagent's Bash tool_use gets 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.

gsdatta · 2 months ago

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

---
defer nondeterministically feeds the model tool_result{is_error:true, "[Tool result missing due to internal error]"} instead of suspending

Adding an API-level reproduction that shows why this happens.

When a PreToolUse hook returns defer, Claude sometimes suspends correctly (turn ends
tool_deferred, no result) — but sometimes runs an extra continuation inference and, in it,
fabricates the deferred tool's result as is_error: true with 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:

#!/bin/sh
read -r line
case "$line" in
  *'"tool_name":"Bash"'*) echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"defer"}}' ;;
  *) echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}' ;;
esac

settings.json (point the command at your hook.sh path):

{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"sh ./hook.sh"}]}]}}

proxy.js — logs /v1/messages request bodies, forwards everything (incl. your auth headers) to Anthropic:

const http = require('http'), https = require('https'), fs = require('fs');
const LOG = './api_log.jsonl';
fs.writeFileSync(LOG, '');
http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    const body = Buffer.concat(chunks);
    if (req.url.includes('/v1/messages'))
      fs.appendFileSync(LOG, '\n@@@ ' + req.url + '\n' + body.toString('utf8') + '\n');
    const headers = { ...req.headers }; delete headers.host;
    const up = https.request(
      { hostname: 'api.anthropic.com', path: req.url, method: req.method, headers },
      r => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
    up.on('error', e => { res.writeHead(502); res.end(String(e)); });
    up.end(body);
  });
}).listen(8788, () => console.error('proxy on :8788'));

Run (repeat a few times — it's nondeterministic)

node proxy.js &
ANTHROPIC_BASE_URL=http://localhost:8788 \
  claude -p 'Use the Bash tool to run: echo hello' \
  --output-format stream-json --verbose --settings ./settings.json

What you see

  • Good run: the proxy logs one /v1/messages request; Claude exits at the tool_use

(stop_reason: tool_deferred). This is the documented behavior.

  • Bad run: the proxy logs two requests. The model produces text like

"The Bash tool returned an internal error…" The 2nd request reveals why — Claude inserted
this for the deferred tool:

{ "role": "user",
  "content": [{
    "type": "tool_result",
    "tool_use_id": "toolu_…",
    "is_error": true,
    "content": "[Tool result missing due to internal error]"
  }]}

(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)

  • Nondeterministic — same input flips between the 1-request (clean) and 2-request (error) paths run to run.
  • Not tool-specific — reproduces with Bash and AskUserQuestion.
  • Not model-specificclaude-sonnet-4-6 narrates the error; claude-opus-4-8 instead silently re-calls the tool.
  • Every version since defer shipped — bisected 2.1.89 (where defer was added) through 2.1.185; all narrate-past at a noisy ~15–65% rate, none reliably clean.
  • All permission modes (default, plan, acceptEdits).

Expected

A deferred tool should never receive an is_error result. Either end the turn tool_deferred
(as the clean runs already do) or supply a neutral "pending/deferred" marker — never
[Tool result missing due to internal error].

sakalys · 1 month ago

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 PreToolUse hook that returns defer synchronously (no awaits) loses the race a majority of the time. Adding await new Promise(r => setTimeout(r, 200)) before returning defer wins the race deterministically across dozens of runs.

The rule seems to be: defer must be returned AFTER the assistant message's stream has fully closed (i.e., the SDK has received message_stop for the message containing the tool_use). If it returns before, the SDK makes a second inference; that second inference sees the synthetic is_error: true tool_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):

import {query, createSdkMcpServer, tool} from '@anthropic-ai/claude-agent-sdk';
import {z} from 'zod';

const DELAY_MS = parseInt(process.env.DELAY_MS ?? '0');
const server = createSdkMcpServer({
  name: 'toy', alwaysLoad: true,
  tools: [tool('act', 'Does a thing.', {x: z.string()},
    async () => ({content: [{type: 'text', text: 'ok'}]}))]
});
const hook = {PreToolUse: [{matcher: 'mcp__toy__act', hooks: [async () => {
  if (DELAY_MS) await new Promise(r => setTimeout(r, DELAY_MS));
  return {hookSpecificOutput: {hookEventName: 'PreToolUse', permissionDecision: 'defer'}};
}]}]};

let close; const done = new Promise(r => close = r);
const prompt = (async function*() {
  yield {type: 'user', parent_tool_use_id: null, message: {role: 'user', content: 'Call the act tool with x="hi"'}};
  await done;
})();

for await (const m of query({prompt, options: {
  model: 'claude-sonnet-4-6', maxTurns: 4, mcpServers: {toy: server},
  permissionMode: 'default', hooks: hook, allowedTools: ['mcp__toy__act'],
  settingSources: [], strictMcpConfig: true,
}})) {
  if (m.type === 'assistant') for (const c of m.message.content)
    if (c.type === 'text') console.log('TEXT:', c.text);
  if (m.type === 'result') { console.log('terminal_reason:', m.terminal_reason); close(); }
}
  • DELAY_MS=0 → non-deterministic; roughly 3-in-5 runs show a fabricated TEXT: The tool returned an internal error... and terminal_reason: completed
  • DELAY_MS=200 → every run: no fallback text, terminal_reason: tool_deferred

Also 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_stop has landed.

gsdatta · 1 month ago

Just replying here that the issue is still relevant so that it doesn't get auto-closed.