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)

Status Open
Reported on v2.1.273
Maintainer reply None cached
Activity 6 comments · opened Sep 16, 2026

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

  • UserPromptSubmit hooks can add context, block, and (per #27365 request) rewrite the prompt. Users build auto-approval, policy, logging and behaviour-shaping hooks on the assumption that prompt is 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_notification messages skip UserPromptSubmit entirely. Together these show the event's semantics for non-typed input are undefined.

Requested fix (any of, in preference order)

  1. Include prompt_source (typed | queued | system | sdk | …) and is_meta in the UserPromptSubmit payload. Cheapest, fully backward compatible, and the values already exist at the call site.
  2. Do not fire UserPromptSubmit for promptSource: "system" / isMeta: true inputs at all, and add a distinct event (e.g. SystemPromptInjected / AgentMessageReceived) for hooks that want them.
  3. At minimum, document that UserPromptSubmit fires for harness- and agent-generated messages and that prompt must 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.

View original on GitHub ↗

3 Comments

floodsys · 28 days ago

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.

alex-feel · 18 days ago

Confirmed on 2.1.280, Windows 11 (hooks run under Git Bash). The repro is one session with a logging UserPromptSubmit hook and an asyncRewake Stop hook.

settings.json:

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "cat >> hooks.log" }] }],
    "Stop": [{ "hooks": [{
      "type": "command",
      "asyncRewake": true,
      "command": "cat > /dev/null; [ -e rewake.done ] && exit 0; touch rewake.done; sleep 5; echo 'Background check finished. Reply with the single word rewoken.' >&2; exit 2"
    }] }]
  }
}

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.

rm -f hooks.log rewake.done
{ printf '%s\n' '{"type":"user","message":{"role":"user","content":"Reply with the single word ok."}}'; sleep 40; } |
  claude -p --input-format stream-json --output-format stream-json --verbose --replay-user-messages \
    --model haiku --settings settings.json --setting-sources project --strict-mcp-config --no-session-persistence \
  > stream.jsonl

hooks.log gets two lines with the same keys, differing only in prompt_id and prompt:

{"session_id":"...","transcript_path":"...","cwd":"...","prompt_id":"fc70d2a5-...","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"Reply with the single word ok."}
{"session_id":"...","transcript_path":"...","cwd":"...","prompt_id":"f8ed72fe-...","permission_mode":"default","hook_event_name":"UserPromptSubmit","prompt":"<task-notification>\n<summary>Stop hook feedback</summary>\n</task-notification>\n<system-reminder>\nStop hook blocking error from command \"Stop\": Background check finished. Reply with the single word rewoken.\n\n</system-reminder>"}

Claude Code knows the origin: in stream.jsonl the woken turn's result event carries "origin":{"kind":"task-notification"}, and the first turn's result has no origin key. 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_SOCKET reaches 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 origin object to the UserPromptSubmit input, next to prompt_source or 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.

alex-feel · 17 days ago

Follow-up on 2.1.280 (Windows 11): two more turn sources reach UserPromptSubmit indistinguishable from typed prompts.

  1. A subagent's report handed back to its caller (under --permission-mode auto) arrives as a prompt that starts <agent-message from="<agent id>"> followed by [Subagent hand-back] ....
  2. A message a named background subagent sends to the main session with SendMessage arrives 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 source field on UserPromptSubmit (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.

Showing cached comments. Read the full discussion on GitHub ↗