canUseTool callback not invoked for background subagent tool calls in default permission mode

Status Closed — not planned
Reported on v2.1.42
Maintainer reply None cached
Activity 11 comments · opened Feb 20, 2026 · closed May 24, 2026

Summary

When using the Claude Agent SDK with a custom canUseTool callback in default permission mode, background subagents spawned via the Task tool (run_in_background: true) do not route tool permission requests through the callback. The SDK denies their tool calls internally without ever invoking canUseTool. Additionally, these internal denials can corrupt the parent session's transport, causing subsequent main-agent tool calls to fail with "Stream closed".

This bug affects default mode specifically. In bypassPermissions mode, everything works correctly. Reproduced with both personal accounts (with bypassPermissions access) and corporate accounts (without), confirming the bug is in default mode handling.

Environment

  • Claude Agent SDK: 0.2.42 (bundled with Claude Code 2.1.42)
  • Platform: Linux (Debian)

Reproduction

A standalone reproduction case is available at: https://github.com/nicolasnoble/claude-sdk-bug-report

git clone https://github.com/nicolasnoble/claude-sdk-bug-report
cd claude-sdk-bug-report
npm install
npx tsx repro.ts
# For non-default credential locations:
CLAUDE_CONFIG_DIR=~/.claude-work npx tsx repro.ts

The script starts a session in default mode with a canUseTool callback that auto-allows all tools and logs every invocation. It triggers Claude to spawn a background subagent that runs a simple Bash command, then reports whether canUseTool was invoked.

Expected output

[canUseTool] canUseTool called: Task (agentID=main, ...)
[canUseTool] canUseTool called: Bash (agentID=main, ...)
[canUseTool] canUseTool called: Bash (agentID=<subagent-id>, ...)

Subagent calls (1+):
  canUseTool called: Bash (agentID=<subagent-id>, ...)

OK: canUseTool was called for subagent Bash calls.

Actual output

[canUseTool] canUseTool called: Task (agentID=main, ...)
[canUseTool] canUseTool called: Bash (agentID=main, ...)

Subagent calls (0):
  BUG CONFIRMED: canUseTool was never called for subagent tool uses.

The subagent reports "Permission to use Bash has been denied" without canUseTool ever being invoked.

Additional observations

  • bypassPermissions mode works correctly: subagent tool calls are properly handled. The bug is specific to default mode.
  • Silent permission mode downgrade: requesting bypassPermissions on an account without access silently falls back to default with no error or warning. The only way to discover this is by inspecting the permissionMode field on the system/init message.
  • Transport corruption: after background subagent permission denials, subsequent main-agent Bash calls fail with "Tool permission request failed: Error: Stream closed". The transport self-heals after several minutes.
  • Background subagents are isolated from all permission mechanisms: we tested all three available approaches and none reach background subagents:
  • canUseTool callback: never invoked for background subagent tool calls
  • allowedTools in query options: does not propagate to background subagents
  • Bash(*) in user-level settings (the same settings file the session uses, confirmed via CLAUDE_CONFIG_DIR): does not propagate to background subagents
  • The canUseTool type definition includes agentID?: string for subagent identification, suggesting the callback is designed to handle subagent tool calls.

Bugs

  1. canUseTool not called for background subagents in default mode: the SDK denies tool calls internally without invoking the callback. Background subagents appear completely isolated from all three permission mechanisms.
  2. Transport corruption: internal subagent denials break the parent session's transport ("Stream closed"), requiring several minutes to self-heal.
  3. Silent permission mode downgrade: no error or warning when bypassPermissions is unavailable - the SDK silently falls back to default.

View original on GitHub ↗

11 Comments

github-actions[bot] · 6 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/23983
  2. https://github.com/anthropics/claude-code/issues/21460

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

nicolasnoble · 6 months ago

These are related but not duplicates:

  • #23983 is about PermissionRequest hooks not firing for subagents
  • #21460 is about PreToolUse hooks not being enforced on subagents

This issue is specifically about the SDK canUseTool callback not being invoked for background subagents, plus a transport corruption bug where the internal denials cause the parent session's Bash to break with "Stream closed" for several minutes.

All three issues likely share the same root cause (subagents don't inherit the parent's permission infrastructure), but the symptoms, affected APIs, and reproduction steps are different. This one includes a standalone reproduction case and documents the transport corruption cascade which the other two don't cover.

nicolasnoble · 5 months ago

Still reproducible on latest Claude Code. The standalone repro at https://github.com/nicolasnoble/claude-sdk-bug-report demonstrates all three symptoms.

nicolasnoble · 5 months ago

Quick update: I've been running SDK 2.1.76 extensively over the past couple weeks with heavy background subagent usage (5+ concurrent background agents doing Bash calls, file edits, grep, etc. in both default and bypassPermissions modes) and haven't been able to reproduce this anymore. The canUseTool callback fires correctly for background tasks, and no transport corruption after denials.

Going to keep monitoring for another week or so before considering this fully resolved, but it's looking good so far.

nicolasnoble · 5 months ago

Correction on my earlier update: the bug is still present in SDK 2.1.76. It reproduced today during a session that used a background subagent to do research (Read/Grep tools). After the subagent completed, Bash transport was permanently dead for the remainder of the session, and MCP tool transports also went down. Built-in tools (Read, Glob, Grep, Edit) continued working.

The reproduction is intermittent - I ran 10+ concurrent background agents yesterday without triggering it, then a single background agent today caused it. The pattern remains: background subagent completes, subsequent Bash calls in the main context fail silently, and the transport never recovers within the session.

LongBash (a separate MCP server wrapping child_process.execSync) worked as a workaround since it uses its own transport.

nicolasnoble · 5 months ago

New reproduction details that significantly narrow the bug:

Reproduction: Launch 5+ background subagent sessions (run_in_background: true) concurrently in default permission mode. The subagents only need to use built-in tools (Read, Write, Glob, Grep) - no Bash or MCP tools required. After all subagents complete successfully, the main context's Bash transport is permanently dead ("Tool permission request failed: Error: Stream closed"). All MCP tool transports also die. Built-in tools continue working.

Key findings:

  • The subagents themselves don't need to use Bash to trigger the corruption. Pure Read/Write to /tmp is sufficient.
  • The bug does NOT reproduce in bypassPermissions mode - only default mode is affected.
  • Foreground subagents (no run_in_background) work fine with Bash in default mode.
  • The corruption appears to be a race condition in the canUseTool permission callback when multiple background agents fire tool calls concurrently. In bypassPermissions mode the callback is skipped entirely, which is why it's unaffected.
  • Once triggered, the transport never recovers within the session. Only a full session restart fixes it.

Workaround: Don't use run_in_background: true for agents that need Bash/MCP tools. Background agents are safe for read-only built-in tools, but running many in parallel will corrupt the main context's transport. Use foreground agents for anything that needs Bash.

nicolasnoble · 5 months ago

Root cause identified by instrumenting sdk.mjs and reading through cli.js (SDK 2.1.76):

The bug has two components:

1. Background agents get shouldAvoidPermissionPrompts: true

In the agent execution path, background/async agents have their toolPermissionContext modified:

// Simplified from cli.js
let isHeadless = canShowPermissionPrompts !== undefined 
  ? !canShowPermissionPrompts 
  : permissionBubble === "bubble" ? false : isAsync;
if (isHeadless) context = { ...context, shouldAvoidPermissionPrompts: true };

For SDK-spawned background agents (isAsync: true, canShowPermissionPrompts: undefined), shouldAvoidPermissionPrompts is always true.

2. Bash is not on the auto-mode tool allowlist

The CLI maintains an allowlist of tools that are safe to auto-approve without prompting (Read, Grep, Glob, Edit, etc.). Bash is deliberately excluded - and rightly so, since Bash can execute arbitrary commands and shouldn't be silently auto-approved in the general CLI case.

The problem is what happens when shouldAvoidPermissionPrompts is true and a tool isn't on this allowlist: the CLI denies the tool internally with decisionReason: { type: "asyncAgent" } without ever sending a can_use_tool control request to the SDK. This means the SDK's canUseTool callback is never invoked for Bash in background agents - the denial happens inside the CLI before it reaches the SDK layer.

3. Transport corruption is a separate downstream effect

I instrumented ProcessTransport.write() with concurrent-write detection and Query.handleControlRequest() with concurrency tracking. Findings:

  • Zero concurrent writes on the stdio pipe (Node event loop serializes them)
  • Zero concurrent can_use_tool control requests
  • Up to 3 concurrent mcp_message control requests during MCP server initialization
  • The "Stream closed" error on the main context appears after heavy concurrent MCP message traffic, not from the permission denials themselves

4. Root cause in the subagent permission wrapper - hardcodes internal permission check

The subagent permission wrapper function constructs a canUseTool for each subagent. It always calls the internal permission checker as its first pass:

// Simplified from cli.js
function createSubagentCanUseTool(agentDef, abortController, onPause) {
  return async (tool, input, ctx, msg, toolUseId) => {
    let result = await internalPermissionCheck(tool, input, ctx, msg, toolUseId);
    if (result.behavior !== "ask") return result;  // short-circuits here on deny
    // ... only reaches interactive prompt / SDK callback if "ask" ...
  };
}

The internal permission checker is hardcoded - even when the CLI is running in SDK mode with a canUseTool callback registered via --permission-prompt-tool stdio. The SDK's callback is the outer canUseTool function that replaces the internal checker at the top level, but the subagent wrapper bypasses it by calling the internal checker directly.

Suggested fix:

The fix is to pass the parent's canUseTool function into the subagent wrapper instead of hardcoding the internal checker. The infrastructure for this already exists:

  1. The CLI already knows it's in SDK mode (sdkUrl is set, permission prompt tool is "stdio")
  2. The top-level canUseTool is already correctly wired to the SDK callback (via createCanUseTool() when permissionPromptTool === "stdio")
  3. The subagent wrapper already receives the parent agent definition - it just needs the parent's canUseTool as an additional parameter
// Current (broken) - hardcodes internal checker:
function createSubagentCanUseTool(agentDef, abortController, onPause) {
  return async (tool, input, ctx, msg, toolUseId) => {
    let result = await internalPermissionCheck(tool, input, ctx, msg, toolUseId);
    ...
  };
}

// Fixed - uses parent's canUseTool:
function createSubagentCanUseTool(agentDef, abortController, onPause, parentCanUseTool) {
  return async (tool, input, ctx, msg, toolUseId) => {
    let result = await parentCanUseTool(tool, input, ctx, msg, toolUseId);
    ...
  };
}

The call site already has access to the parent's canUseTool (it's a parameter of the enclosing agent execution function), so threading it through is straightforward.

Alternatively, the shouldAvoidPermissionPrompts deny path (the type: "asyncAgent" denial) could check for requireCanUseTool on the toolUseContext and return "ask" instead of "deny" when the SDK callback is registered. This field already exists and is used by the speculation system for exactly this purpose.

houserooms · 5 months ago

Still broken as of 25 March 2026. Filed #38859 and #38662 with current version details and new observations (parallel calls bypass, sequential don't; agents completely blocked).

himanshuseth · 4 months ago

This is a prerequisite for background subagent permission escalation (see #47339). If canUseTool fired for background agents, the runtime could intercept auto-denies and route them to the parent session instead. Currently background agents silently fail on any unapproved tool call.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

github-actions[bot] · 1 month 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.