Prompt injection committed as a user message via --replay-user-messages (VS Code extension), not reproducible in bare CLI

Open 💬 0 comments Opened Jun 30, 2026 by nao-amj

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 as
type=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 (UserPromptSubmit etc.): definitions + 5 hook scripts read — only emit

benign context nudges.

  • Fetched content: no WebFetch/WebSearch used; 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.

View original on GitHub ↗