PreToolUse:Agent/Task hook results are checked ~140ms after tool_dispatch_end — cannot block subagent dispatch
Description
An async PreToolUse:Agent/PreToolUse:Task hook's stdout/exit-code result is checked by the harness after the subagent tool call has already dispatched and fully completed — not before or during dispatch. This means a hook designed to block a subagent dispatch (via permissionDecision: "deny" or exit code 2 + stderr) cannot actually prevent the subagent from running, even when the hook computes the correct decision in time.
Version
Claude Code CLI 2.1.220
Evidence — real production timestamps from ~/.claude/debug/<session-id>.txt (claude --debug hooks)
One complete Agent-tool dispatch cycle, captured live:
21:18:05.229Z Hooks: Registering async hook async_hook_34311 (PreToolUse:Agent) with timeout 5000ms
21:18:05.318Z [INFO] [Stall] tool_dispatch_start tool=Agent toolUseId=toolu_011vd36gY3jspxkT73nW5YrS permissionDecisionMs=0
21:18:05.400Z [API:timing] dispatching to firstParty model=copilot/standard
21:18:05.401Z [API REQUEST] /v1/messages source=agent:builtin:general-purpose
21:18:08.606Z Stream started - received first chunk (first byte after 3207ms)
21:18:08.691Z [INFO] [Stall] agent_completion agentId=... exitPath=completed durationMs=3370 turns=1 finalStopReason=end_turn
21:18:08.691Z [INFO] [Stall] tool_dispatch_end tool=Agent toolUseId=toolu_011vd36gY3jspxkT73nW5YrS outcome=ok durationMs=3373
21:18:08.830Z (~139ms AFTER tool_dispatch_end) Checking hook async_hook_34311 (PreToolUse:Agent) - attachmentSent: false, stdout length: 3
21:18:08.831Z Found sync response from async_hook_34311: {}
21:18:08.831Z Hook PreToolUse:Agent (PreToolUse) success: {}
21:18:08.831Z Hooks: async_hook_34311 (PreToolUse:Agent) produced no response payload — skipping attachment
Note permissionDecisionMs=0 at tool_dispatch_start — the harness appears to record zero elapsed time waiting on a permission decision from the hook before dispatching, consistent with dispatch proceeding without waiting for the hook's result at all. The subagent's own model call is issued at 05.400Z/05.401Z, streams its full response, and reaches tool_dispatch_end (durationMs=3373) at 08.691Z — before Claude Code even reads async_hook_34311's stdout at 08.830Z, ~139ms later.
Steps to Reproduce
- Register a
PreToolUsehook matchingAgent/Task(e.g."matcher": "^(Task|Agent)$") that inspectstool_inputand can return{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", ...}}or exit 2 + stderr for some condition. - Trigger a subagent dispatch (via the
Agent/Tasktool) that should match the hook's deny condition. - Run the session under
claude --debug hooksand inspect~/.claude/debug/<session-id>.txt. - Observe:
tool_dispatch_start→ the subagent's own/v1/messagesrequest →agent_completion/tool_dispatch_end(outcome=ok) all complete, and only afterward does the harness check the hook's registered stdout.
Expected
A blocking PreToolUse:Agent/PreToolUse:Task hook's decision should be evaluated (or at minimum its timeout awaited) before the subagent dispatch proceeds, matching how PreToolUse hooks gate Bash/Write/Edit calls (where permissionDecisionMs is non-zero and the tool call visibly waits).
Actual
The subagent dispatch proceeds and completes fully before the async hook's result is even read. A deny/exit-2 response arriving after tool_dispatch_end has no way to prevent the already-completed dispatch.
Impact
Any PreToolUse:Agent/PreToolUse:Task hook intended to enforce a policy on subagent dispatch — cost-tier routing, model allowlisting, subagent-type restrictions, data-access scoping — is unenforceable via this event today, independent of whether the hook script itself is correct. I believe this may also explain the intermittent-looking behavior in #73380 (a permissions.deny + PreToolUse hook correctly blocking a second subagent's access but not a first one under otherwise-identical settings) — a race between when a subagent's internal tool calls happen relative to when the harness checks that subagent's own inherited hook state would produce exactly that kind of "sometimes blocks, sometimes doesn't" symptom, though I haven't confirmed the two share a single root cause.
Additional context
This surfaced while debugging why a third-party plugin's (ruvnet-brain) PreToolUse:Agent cost-routing gate (route-dispatch.sh) never appeared to block anything in production, despite being correctly registered and invoked with the real tool-call payload. A separate, independent bash bug in that same script also contributes to the observed silence (tracked upstream at https://github.com/stuinfla/ruvnet-brain/issues/80) — but even with that bug fixed, the timing shown above means a correctly-computed deny decision still cannot stop the dispatch. Filed the harness-timing finding upstream as https://github.com/stuinfla/ruvnet-brain/issues/84 as well, for that plugin's own tracking.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗