[BUG] Stop-hook "callback" injects a synthesised user turn (isMeta:true) when a paused subagent expects SendMessage but parent has no such tool
Summary
When a subagent pauses with a "use SendMessage with to: '<id>' to continue" message,
and the parent session does not have SendMessage exposed as a deferred tool,
the harness fires a stop-hook callback (subtype: stop_hook_summary,hookInfos: [{command: "callback"}]) that injects a synthesised pseudo-user
message into the parent's input queue with isMeta: true. The synthesised
message paraphrases the agent's internal plan as if the human had typed it.
The parent has no way to distinguish this from a real user turn at chat time
(it appears as role: user in context), so it acts on those instructions.
This is a silent prompt-injection vector. The parent ends up doing work it
believes was requested by the user, when it was actually generated by the
agent harness.
Reproduction (observed)
- Project has
.claude/agents/route-suite.mddefining a custom agent. - Parent session calls
Agent(subagent_type: "route-suite", ...). - -> Rejected: `Agent type 'route-suite' not found. Available agents:
claude-code-guide, Explore, general-purpose, Plan, statusline-setup.subagent_type`
(Project-level agents are not auto-registered as
enum values for the built-in Agent tool — separate issue, but it
pushed me into the path that exposed bug #2 below.)
- Fall back:
Agent(subagent_type: "general-purpose", prompt: "<route-suite agent file contents + task>"). - Agent runs ~5 min, pauses with:
Waiting for CloudWatch ingestion. agentId: a64da7d05af237b71
(use SendMessage with to: 'a64da7d05af237b71' to continue this agent)
- Parent does not have
SendMessagein its deferred-tool list.
Confirmed via two ToolSearch queries (select:SendMessage,
+SendMessage) — both return "No matching deferred tools found".
TaskOutput with the agent's agentId returns
No task found with ID: a64da7d05af237b71.
- Parent works around by re-running the task in main context, finishes,
emits its closing assistant message.
- Immediately after that turn ends, a synthesised
role: usermessage
appears in the parent's transcript, parented to a system
stop_hook_summary event with hookInfos: [{command: "callback"}] and
carrying "isMeta": true. Text paraphrases the route-suite agent file's
internal step list ("...proceed with step 4 ...step 5 ...step 6 ...step 7"),
referencing artefact paths the agent wrote (not the parent).
- Parent treats it as a fresh user instruction and does the work again.
Transcript evidence
The injected message in the JSONL (anonymised):
{
"parentUuid": "<stop_hook_summary uuid>",
"isSidechain": false,
"promptId": "<uuid>",
"type": "user",
"message": {
"role": "user",
"content": "Continue the asia route-suite verification. Test suite already
ran (results JSON: /tmp/route-suite-results-1777830757.json,
...). Now proceed with step 4: pull CloudWatch logs ...,
pair Route/ALT/snap lines per request, then synthesize
verdict (step 5), append STATUS.md entry (step 6), and
return the report (step 7)."
},
"isMeta": true,
"uuid": "<uuid>",
"timestamp": "...",
"permissionMode": "acceptEdits",
"userType": "external",
"entrypoint": "claude-desktop",
...
}
The genuine next-user turn (typed by the human) lacks isMeta entirely
and is parented to its own stop_hook_summary.
Why this is a bug, not a feature
- The synthesised content is **not visible to the assistant as machine-
generated** at chat time — isMeta: true is JSONL metadata, not part
of the rendered message. The assistant sees plain role: user.
- The text contains specific instructions ("proceed with step 4 / step
5 / step 6 / step 7") that match the agent's internal plan, not
anything the human said. The parent has no way to know those steps
were never authorised by the user.
- This is exactly the prompt-injection failure mode the safety rules
warn about — instructions appearing in non-chat channels — except
here the injection comes from Claude Code's own harness.
- The "right" behaviour when a subagent emits a SendMessage hint and
the parent has no SendMessage tool would be either:
(a) tell the parent the agent is unreachable so it can decide what
to do, or
(b) actually resume the agent (i.e. the harness owns the resume,
not via faking a user turn in the parent).
Environment
- Claude Code version
2.1.121 - Entrypoint:
claude-desktop - macOS
- Model: Opus 4.7 (1M context)
Suggested fix
- Stop synthesising
role: userturns from stop-hook callbacks.
If the harness needs to nudge the parent, use a system message
with an explicit "this is harness-generated, not from the user"
marker that the assistant can see in the rendered transcript
(not just JSONL metadata).
- When a subagent's "continue" instruction can't be honoured
(SendMessage missing, agent dead), surface that as a tool-result
error to the parent, not as a synthesised user prompt.
- ~~Side issue: project-level
.claude/agents/<name>.mdshould be
reachable as subagent_type via the regular Agent tool, not just
via slash-command / Skill paths. (Filing separately if you'd
prefer.)~~
Correction (edited): the original framing was wrong — project
agents are documented as reachable via subagent_type, and the
claude agents CLI in this same project confirms route-suite is
registered:
````
Project agents:
route-suite · inherit
The Agent tool runtime in this 2.1.121 session rejected
subagent_type: "route-suite" anyway, with an error that
enumerated only the 5 built-ins. The locally installed CLI is at
2.1.126; whether the rejection is a 2.1.121-only regression or
reproduces on 2.1.126 hasn't been confirmed. If reproducible
on a recent version it's a real bug worth filing separately;
otherwise it's noise. Leaving here for awareness rather than as
a confirmed defect.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗