Prompt injection committed as a user message via --replay-user-messages (VS Code extension), not reproducible in bare CLI
Prompt injection appears as a user message via --replay-user-messages (VS Code extension), not reproducible in bare CLI
Summary
In the VS Code extension, foreign prompt-injection content was committed into the
conversation as a legitimate type: user / role: user message. The same workload
(same files, same CLI binary, same plugins) run through the bare CLI (claude -p)
shows zero injection. The single differing variable is the VS Code extension's
transport path, which launches the CLI with --input-format stream-json.
--replay-user-messages
The injected content was not present in any local file, hook, or fetched content.
It surfaced only inside the extension's io_message transport layer and was then
replayed into the CLI conversation as a user turn.
This let the model briefly treat attacker-controlled text as if the user had typed it.
Environment
| | |
|---|---|
| OS | macOS 14.6 (arm64) |
| Claude Code CLI | 2.1.196 |
| VS Code extension (anthropic.claude-code) | 2.1.196 (latest on npm latest) |
| VS Code | 1.126.0 |
Extension-launched CLI flags (relevant subset):
--input-format stream-json --output-format stream-json
--replay-user-messages --include-partial-messages
--permission-prompt-tool stdio --resume <session> --verbose --debug
Evidence (injection text redacted; structure + timestamps only)
Two independent logs from the same session correlate to the millisecond.
Extension UI/transport log (Claude VSCode.log):
14:57:33.480 type=request dir=received
14:57:33.487 type=io_message dir=received,sent <-- injection body appears here
CLI conversation log (<session>.jsonl), same wall-clock instant:
14:57:33.498 type=queue-operation
14:57:33.499 type=queue-operation
14:57:33.532 type=user role=user <-- injection committed as USER turn
14:57:33.563 type=attachment
So the chain is:extension io_message (received+sent) → stream-json + --replay-user-messages
→ CLI commits it as type=user role=user.
Earlier injection instances in the same session (timestamps 14:42–14:53) appeared astype=response with a 32-hex requestId, distinct from the extension UI's own
11-char request IDs — i.e. a different ID namespace than user-initiated turns.
The injected payloads included (redacted) strings characteristic of an external
injection attempt rather than a genuine system message: a fake "the user sent the
following message … respond to this message instead" wrapper, and content labeled with
a non-Anthropic format name (oai_harmony…). A genuine Claude safety reminder does
not label itself with another vendor's format, does not impersonate the user, and does
not instruct the model to hide the message — so these are clearly injected, not platform
reminders.
Control experiment (what isolates the extension path)
| Run | Path | Injection? |
|---|---|---|
| Affected session | VS Code extension (stream-json + --replay-user-messages) | Yes (logged) |
| Control A | bare claude -p (no extension), same files | No |
| Control A' | bare claude -p (stdin closed, multi-tool review), same files | No |
Same CLI binary (2.1.196), same working directory, same plugins. Only the extension
transport differs.
What was ruled out (with evidence)
- Workspace files: full-text scan incl. binary
.npz(pure numeric arrays), file
names, AppleDouble, xattrs — clean.
- User hooks (
UserPromptSubmitetc.): definitions + 5 hook scripts read — only emit
benign context nudges.
- Fetched content: no
WebFetch/WebSearchused; injection predates any file read. - Claude-in-Chrome MCP bridge: bridge socket (
/tmp/claude-mcp-browser-bridge-*.sock)
was idle (last-modified 09:26; injection at 14:42–14:57); not connected during the
session. Ruled out.
What remains unknown
What wrote the injection payload into the extension's io_message in the first place
is not visible in local logs (the body's origin isn't recorded upstream of io_message).
Determining that likely requires inspection of the extension's bundled extension.js.
Impact
No destructive action occurred. The point of concern is that --replay-user-messages
appears able to commit non-user-originated content as a role: user turn, which defeats
the model's ability to distinguish operator input from injected text.
Repro hint
Run an extension session with --input-format stream-json --replay-user-messages and
inspect both Claude VSCode.log (io_message entries) and the session .jsonl
(type=user entries) for content that the operator did not type. Correlate by timestamp.