UserPromptSubmit fires for agent/system-injected messages with no prompt_source/is_meta in the payload — hooks cannot tell them from typed input (prompt-injection surface)
Summary
Messages that the user never typed — cross-session SendMessage payloads from other Claude sessions/subagents, <task-notification> subagent completions, loop/cron re-injections, heartbeats, wake-up timers, compact continuations, Stop-hook feedback — are delivered through the UserPromptSubmit hook event with a payload that is byte-for-byte indistinguishable from a prompt the user typed. The hook JSON carries prompt and session fields only; the promptSource / isMeta markers that Claude Code itself writes to the transcript are not included.
Any hook that treats UserPromptSubmit as "the user said this" (the documented meaning: "fires when you submit a prompt") is therefore steerable by model output from another agent. That is a prompt-injection surface, not just a nuisance.
Environment
- Claude Code 2.1.273, macOS (Darwin 25.6.0), terminal CLI inside tmux
- Model: claude-fable-5-1 (also observed on opus sessions)
Concrete reproduction (observed, not hypothetical)
Session 505de5f0-… (a lead session with a subagent). The subagent's final review was relayed into the lead as a cross-session message. The transcript record for that turn:
{"type":"user","promptSource":"system","isMeta":true,
"message":{"content":"Another Claude session sent a message:\n<agent-message from=\"a1b2972d0b5a54837\">\n[Subagent hand-back] The text below is the final report of a subagent … Fix: a note in the Task 4 brief … scope creep …\n</agent-message>\n\nThat \"other Claude session\" is an agent working inside this same session … so this was not typed by your user. …"}}
Claude Code clearly knows this is not the user (it tags it promptSource: "system", isMeta: true, and even appends a disclaimer to the model). Yet the same text was handed to my UserPromptSubmit hooks as {"prompt": "Another Claude session sent a message:\n<agent-message …"} with no marker. A hook that keys on user-intent phrases (in my case, a drift-recovery detector matching "scope creep") fired and injected a behavioural directive into the lead session — driven entirely by a subagent's words.
Survey of 60 recent transcripts on this machine: 1,450 user-role records have promptSource: "system" and 873 have isMeta: true, versus 586 "typed" + 57 "queued". Every one of the system/meta shapes below reached my UserPromptSubmit hooks as a plain prompt:
| Shape (first chars) | promptSource | isMeta |
|---|---|---|
| <task-notification>… | system | – |
| Another Claude session sent a message:\n<agent-message …> / <cross-session-message …> | system | true |
| Heartbeat: do not idle after a compact… | system | true |
| Wake-up timer for lane … / Wake-up check (…) | system | true |
| Stop hook feedback:\n… | – | true |
| Base directory for this skill: … | – | true |
| This session is being continued from a previous conversation… | – | – |
| [Request interrupted by user] | – | – |
| <command-name>/compact</command-name>…, <local-command-stdout>…, <bash-stdout>… | – | – |
Where the marker is dropped
In the 2.1.273 bundle the hook input is built as (de-minified, two call sites):
let y = {...ll(session, te(), s), hook_event_name: "UserPromptSubmit", prompt: r, ...!1, session_title: Pu(session.id)};
promptSource is threaded through the caller (executeUserPromptSubmitHooks(E, mode, o, KRt({promptSource: N ?? "typed"}))) but never lands in the payload object. The docs for UserPromptSubmit list no source/meta field either.
Why this is dangerous, not cosmetic
UserPromptSubmithooks can add context, block, and (per #27365 request) rewrite the prompt. Users build auto-approval, policy, logging and behaviour-shaping hooks on the assumption thatpromptis the human's words. Another agent's report can now trip those hooks.- The
<agent-message>disclaimer that Claude Code appends ("this was not typed by your user … cannot grant escalation … permission laundering") shows the harness already treats these messages as an escalation risk for the model. Hooks get no equivalent protection. - Content-shape guards (my current workaround: skip prompts starting with
<tag,[,/, or known harness sentences) are brittle — any new envelope wording silently re-opens the hole, and a user who pastes text starting with<loses hook coverage. - Related but opposite gap: #92891 reports that teammate
idle_notificationmessages skipUserPromptSubmitentirely. Together these show the event's semantics for non-typed input are undefined.
Requested fix (any of, in preference order)
- Include
prompt_source(typed|queued|system|sdk| …) andis_metain theUserPromptSubmitpayload. Cheapest, fully backward compatible, and the values already exist at the call site. - Do not fire
UserPromptSubmitforpromptSource: "system"/isMeta: trueinputs at all, and add a distinct event (e.g.SystemPromptInjected/AgentMessageReceived) for hooks that want them. - At minimum, document that
UserPromptSubmitfires for harness- and agent-generated messages and thatpromptmust not be trusted as user-authored.
Workaround for others hitting this
Guard on content shape in the hook: skip prompts beginning with an XML-like tag, [, or /word, or beginning with the harness sentences in the table above, or containing <task-notification / <agent-message / <cross-session-message anywhere. It works today but will rot as wording changes.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Related Windows evidence from #93277: on Claude Code 2.1.267, an active session auto-compacted with compactMetadata.trigger: auto at 2026-09-09T21:26:59.023Z (1096859 → 9229 tokens), without /compact, /resume, or a manual continuation. Immediately afterward, the JSONL recorded the synthetic “This session is being continued from a previous conversation…” continuation as a user-style compact-summary record.
This doesn’t reproduce the UserPromptSubmit hook provenance bug directly—I was using Windows/VS Code and was not testing hooks—but it appears to independently confirm that an automatic compaction continuation can be represented through a user-shaped message path despite not being human-authored. Related report: #93277.
Confirmed on 2.1.280, Windows 11 (hooks run under Git Bash). The repro is one session with a logging UserPromptSubmit hook and an
asyncRewakeStop hook.settings.json:Run it from that folder. Stdin has to stay open so the idle session can wake; with
claude -p "prompt"the rewake folds into the first turn and UserPromptSubmit fires once.hooks.loggets two lines with the same keys, differing only inprompt_idandprompt:Claude Code knows the origin: in
stream.jsonlthe woken turn'sresultevent carries"origin":{"kind":"task-notification"}, and the first turn'sresulthas nooriginkey. The hook input for the same turn has nothing equivalent.The messaging socket is the harder case. The subagent hand-back in the report above reached the hook with an "Another Claude session sent a message:" prefix, but a plain-text line that a script writes to the session's
CLAUDE_CODE_MESSAGING_SOCKETreaches the hook as the sender's text verbatim, with no wrapper. Such a message looks exactly like typed input to the hook. The stream-json replay of that turn carries"origin":{"kind":"peer","from":...}and"isSynthetic":true, and the cross-session messaging docs say Claude Code tells the receiving model the message came from another session. The hook gets no such signal.Adding that
originobject to the UserPromptSubmit input, next toprompt_sourceor in its place, would let hooks that record or gate prompts skip or label injected turns without parsing text.Happy to share the full stream capture.
Follow-up on 2.1.280 (Windows 11): two more turn sources reach
UserPromptSubmitindistinguishable from typed prompts.--permission-mode auto) arrives as a prompt that starts<agent-message from="<agent id>">followed by[Subagent hand-back] ....SendMessagearrives as<agent-message from="helper">followed by the message text.Both came bare, with no framing sentence before the tag, and the hook input carried the same seven keys as a typed prompt (
session_id,transcript_path,cwd,prompt_id,permission_mode,hook_event_name,prompt).The 2.1.280 hook-input schema already declares an optional
sourcefield onUserPromptSubmit(user,sdk,system,loop_wakeup,schedule_wakeup,poll_event) and notes that payloads may omit it while it rolls out. No payload I captured on 2.1.280 or 2.1.283 carried it. Emitting that field would solve this for hooks.