remote-control (ccr transport) re-delivers tool_result chunks, causing sticky context corruption

Status Fixed / completed
Maintainer reply None cached
Activity 4 comments · opened Jun 14, 2026 · closed Jun 26, 2026

Preflight

  • Searched existing issues; not reported.
  • Single bug report.
  • On latest version (v2.1.177).

What's Wrong?

When connecting to a persistent host via the Claude Desktop "code" path (remote-control ccr transport) and running tool-heavy work, tool_result content gets corrupted:

  • (a) whole lines duplicated (same line repeated hundreds–~1000×), and
  • (b) in bad cases, fabricated data that the model then treats as real and builds reasoning on.

Once it starts, it persists for the rest of the session (sticky). Doing the same work on the same host over SSH (plain local claude) does not reproduce it.

Root cause (from v2.1.177 bundle analysis): the transport capability matrix shows fanout/presence/catchupReplay are ccr-only (ssh/direct have them off):

ccr:    { fanout:1, presence:1, catchupReplay:1, bashExec:1, fileRead:1 }
ssh:    { fanout:0, presence:0, catchupReplay:0, bashExec:0, fileRead:1 }
direct: { fanout:0, presence:0, catchupReplay:0, bashExec:0, fileRead:0 }

The ccr bridge spawns the SDK child with --replay-user-messages and the bridge handler holds a FIFO dedup cache resolvedToolUseIds capped at 1000 (trackResolvedToolUseId evicts the oldest tool_use_id once size > 1000). Two ccr-only mechanisms fit the symptom:

  • M1 (primary suspect, explains "sticky"): after >1000 tool uses the oldest tool_use_ids are evicted; a catchupReplay/replay carrying an evicted id is no longer recognized as resolved → the tool_result is re-delivered. Eviction is irreversible and ids keep growing → stays broken for the rest of the session.
  • M2: catchupReplay on detach→reattach (or a 2nd viewer joining via fanout) replays the outbound buffer from a miscounted cursor → already-delivered chunks re-emitted; cursor stays wrong → sticky.

Host-side transcripts (~/.claude/projects/.../*.jsonl, 49 scanned) contain zero line-duplication, confirming the corruption is injected in the ccr relay/replay leg — downstream of the persisted model context. This is also why "use a single session" did not fix it: parallelism only raises the chance of triggering a replay event; it is not the root cause.

What Should Happen?

tool_result delivered over ccr should be byte-identical to what the SDK child produced — no duplication, no re-delivery — regardless of session length, tool-use count, or detach/reattach.

Error Messages/Logs

No error is surfaced; the symptom is silent data corruption. Evidence is the ccr-only capability matrix above and the resolvedToolUseIds FIFO cap of 1000 in the bridge handler.

Steps to Reproduce

  1. Connect to a persistent host via Claude Desktop "code" (remote-control ccr transport).
  2. Run tool-heavy work (many Bash/Read calls; drive the tool-use count toward ~1000). To target M2, detach→reattach Desktop partway through.
  3. Observe tool_result line duplication / fabricated data that then persists for the rest of the session.
  4. Decisive capture: start the bridge with --verbose --debug-file <path>, capture the relay stream-json, and diff it against the SDK child's project *.jsonl. The first divergence localizes the injection point (host transcript clean, relay duplicated).

Falsifiable predictions: M1 → onset correlates with cumulative tool-use ≈1000; M2 → onset correlates with a detach/reattach event, independent of tool count.

Is this a regression?

Not sure — SSH/direct paths are unaffected; only the ccr (Desktop "code") path corrupts.

Suggested fix

  • Keep resolvedToolUseIds monotonic for the whole session, or dedup at re-delivery time on a per-emitted-message cursor/ack rather than on tool_use_id alone.
  • Pin the catchupReplay cursor to the acked offset and forbid resetting it on reattach.

Environment

  • Claude Code v2.1.177 (Bun single-executable), Linux x86_64.
  • Path that reproduces: Claude Desktop "code" → remote-control (ccr).
  • Path that does NOT reproduce: SSH → local claude (ssh/direct).

View original on GitHub ↗

4 Comments

YukouTashiro · 3 months ago

**Additional repro on v2.1.181 — fabricated content surfaced as a phantom (non-persisted) user message, not duplicated tool_result lines**

Hit variant (b) ("fabricated data that the model then treats as real and builds reasoning on") again on 2026-06-18 via the same ccr / Desktop "code" path. New detail worth flagging: this time the fabrication did not show up as duplicated tool_result lines — it surfaced as an entire phantom user turn that the model reacted to, but which was never persisted to any transcript.

What happened

  • After an Orient request + a few tool calls, the model produced a reply reacting to a user message reading roughly: 残タスクは全て完了した。drive撤去、別PCもクローンした。サーバーは切り替えた。… Gドライブもクローンされたから安心だね.
  • I never typed that, and its content was factually inverted vs the actual machine state (it claimed teardown steps were done that had not been done) — so the model built a whole (wrong) narrative on it before I caught it.

Forensic evidence (from the session .jsonl)

  1. The disputed string never appears as a genuine type=user / role=user text input in any ~/.claude/projects/**/*.jsonl — only as later assistant quotes and (eventually) my own dispute message quoting it back.
  2. The persisted thread goes straight from the Orient user turn → the model's Read calls → an assistant reply that already reacts to the phantom message, with zero user messages in between.
  3. The model's intermediate Orient summary (the assistant turn that should sit between the Reads and that reply) is also missing from the transcript — consistent with an interrupt-time replacement: the phantom turn carried a [Request interrupted by user] marker.

Suggested triage signature

  • an assistant turn reacts to a user message that has no corresponding persisted type=user record;
  • the fabricated content is absent as a user input across all local transcripts (so it's not a session-file mixup → transport/buffer level, matching the ccr-only fanout/catchupReplay root-cause analysis above);
  • an assistant turn goes missing around an interrupt boundary.

Env

  • v2.1.181 (4 patches after the original v2.1.177 report; still reproduces).
  • ccr transport (Desktop "code" → persistent tmux host). Same host over plain SSH claude does not reproduce, matching the original report.
YukouTashiro · 3 months ago

Follow-up — security implication: what does the auto-approval / Bash permission classifier ground its decision on?

Building on the repro above, there's a security-relevant escalation worth confirming.

During a contamination episode the corruption lands in the model's output (the generated tool-call command strings), because the model reasons over poisoned context. The only thing standing between that and execution is the permission layer. So the critical question is:

When a Bash command is auto-approved (allowlist / acceptEdits / --dangerously-skip-permissions), does the approval decision read the same (corruptible) model context / conversation, or is it grounded in the persisted real user input (*.jsonl user turns)?
  • If the classifier evaluates against the model-side context, then a fabricated destructive command (e.g. an rm -rf that the model "decided" to run based on a phantom user instruction) can be auto-approved, because the classifier is fed the same poisoned stream. Contamination → fabricated command → auto-approval, with no real-input check anywhere in the path.
  • If it cross-checks against the persisted user input ("is this destructive op actually justified by something the user really typed?"), the blast radius is bounded even under contamination.

Why this matters for #68434 specifically: this is the variant-(b) failure mode ("fabricated data the model treats as real") combined with action, not just reasoning. In my repro the fabrication only produced wrong analysis; but the same mechanism could, in principle, produce a destructive tool call, and whether that gets stopped depends entirely on the classifier's grounding source.

Requests:

  1. Confirmation of what the auto-approval path is grounded on (model context vs persisted user input).
  2. If model-context-grounded: consider grounding destructive-op approval (or at least a confirmation gate) on persisted user turns, so a sticky contamination episode cannot manufacture both the command and its approval.

Practical mitigation in the meantime (for anyone hitting this): keep Bash permission prompts on when using the ccr / Desktop "code" path, and do irreversible/destructive work over plain SSH claude (which doesn't reproduce the corruption).

YukouTashiro · 3 months ago

Follow-up on v2.1.183 — SSH-launched claude + remote-control/fork left an orphan daemon, and the live session had read a different project's session log

I hit another variant today. This one is slightly different from my previous ccr/Desktop-only reports: the session was originally started over plain SSH, but remote-control (/rc active) and a fork lane were active during the contaminated state.

New evidence

After I stopped the SSH-launched interactive claude, several Claude Code processes remained alive:

  • a transient daemon was still running as an orphan (PPID=1);
  • that daemon's argv still recorded it had been spawned by the now-gone interactive claude process;
  • a fork-session process was still alive, resumed from the current project session JSONL;
  • a spare bg pty host was also still alive;
  • the fork-session still had MCP children (codegraph, qmd) and active HTTPS connections.

Process shape, redacted:

systemd,1
  `-claude,<daemon-pid> daemon run --origin transient --spawned-by {"label":"claude","cwd":"<project-A>","pid":<stopped-parent-pid>}
      |-2.1.183,<pty-host-pid> --bg-pty-host /tmp/cc-daemon-.../pty/<fork>.sock ...
      |   `-2.1.183,<fork-pid> --session-id <fork-session-id> --fork-session --resume ~/.claude/projects/<project-A>/<session-A>.jsonl --effort xhigh --permission-mode auto
      |       |-node ... codegraph serve --mcp
      |       `-node ... qmd mcp
      `-2.1.183,<spare-pty-host-pid> --bg-pty-host /tmp/cc-daemon-.../spare/<spare>.pty.sock ...
          `-2.1.183,<spare-pid> --bg-spare /tmp/cc-daemon-.../spare/<spare>.claim.sock

Daemon log excerpt:

[2026-06-19T06:55:18.730Z] [supervisor] daemon start version=2.1.183 pid=<daemon-pid> origin=transient
[2026-06-19T06:55:18.768Z] [bg] bg spawned <fork> (slash)
[2026-06-19T06:55:18.769Z] [bg] bg spawned <spare> (spare)
[2026-06-19T06:55:28.799Z] [bg] bg claimed-spare <older-spare> (fleet)
[2026-06-19T06:55:28.802Z] [bg] bg spare spawned host pid=<spare-host-pid>
[2026-06-19T07:17:48.769Z] [bg] bg settled <spare> (killed)

Strongest anomaly

Before I stopped the interactive SSH claude, I observed this:

interactive claude pid: <stopped-parent-pid>
cwd: <project-A>
command: claude

But the same process had a read fd open to a different project's session log:

/proc/<stopped-parent-pid>/fd/76 -> ~/.claude/projects/<project-B>/<session-B>.jsonl
/proc/<stopped-parent-pid>/fdinfo/76:
  pos:   65536
  flags: 0100000

So while running in project A, the live interactive Claude process had read at least the first 64 KiB of project B's session JSONL. This looks like cross-project/session context access, not just a UI display artifact.

UI state during the incident

The tmux/Claude UI showed:

/rc active
main + fork lanes visible
Capacity: 5/32
New sessions will be created in the current directory

The model-visible contamination also looked like genuine Claude Code harness/fork content entering the wrong conversation. The session itself described a fork-boilerplate-shaped message appearing in the main conversation, which matches this local process evidence.

What I did not see

  • No current ccr process was present in the process list at the time of this snapshot.
  • I did not observe a non-Claude external process connected to the Claude pty/control/rv sockets.
  • The residual process tree was all under the Claude Code transient daemon.

Why this may be useful for triage

This suggests the issue may not be limited to ccr/Desktop transport replay. There may also be a remote-control/fork-session cleanup or routing bug where:

  1. a transient daemon/fork outlives its parent interactive session;
  2. fork/spare state remains active with pty/rv/control sockets and MCP children;
  3. the live interactive process can read a session log from another project;
  4. harness/fork-context messages can then appear in the wrong conversation.

I have a local forensic snapshot with full commands and raw outputs. I am not posting the full JSONL publicly because it contains private project/user context and large model payloads, but I can provide the raw daemon.log and the two relevant session JSONLs privately if useful.

github-actions[bot] · 1 month ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.