Skip to content

apply: role=tool may leak onto the Anthropic wire when a rewritten tool message survives a count change #113

Description

@amiddavid

rebuildCountChanged decides which messages to re-emit verbatim by byte-matching each output
message against its pre-pipeline normalized form (apply/apply.go:1344: "Each output message that
byte-matches a pre-pipeline normalized message (a survivor) is emitted as its ORIGINAL body raw
bytes"). A message that does not match is marshaled fresh.

For a synthetic role=tool message that is unsafe. Anthropic has no tool role — the synthetic
message is this proxy's internal representation of a tool_result content block. So if any
component rewrites a tool message's text (format compacting indented JSON is the ordinary case,
and it rewrites hundreds per run) and the message count changes (summarize), the rewritten
message no longer byte-matches, falls through to a fresh marshal, and the internal role reaches
the wire:

400 messages: Unexpected role "tool"

The equal-count path already avoids this by writing tool-text rewrites into the body's
tool_result blocks with sjson (apply/apply.go:151: "a change rewrites only that block's
content"). The count-change path does not.

Status: plausible, NOT yet reproduced on main

Being explicit because it matters for triage: this is inferred from reading the mechanism, not
observed. It was a real, measured defect on #80 — 12 of 39 live runs failed with the error above
once an earlier fix stopped tool results being deleted, which had been masking it — and caf32d7
fixed it there by (a) writing tool-text rewrites into the body's tool_result blocks before the
rebuild, so the rebuild only decides which messages to keep and never how to serialize one, and
(b) matching synthetic tool messages by tool_call_id rather than by bytes, since their text may
legitimately differ from the pre-pipeline form.

main's apply.go has since been reworked substantially and caf32d7 does not cherry-pick — it
conflicts in three places. Hand-resolving conflicts inside the byte-losslessness machinery is
exactly what #80's own commit warned against ("a careless change risks the byte-losslessness
guarantee this package exists to provide"), so it was not attempted in #110.

Suggested order of work

  1. Reproduce first. A test with an Anthropic body whose tool_result carries indented JSON
    (so format actually rewrites it — see below), a pipeline of [format, summarize] so the count
    changes, asserting no role: "tool" appears anywhere in the emitted body. Confirm it fails on
    main before changing anything.
  2. Then port caf32d7's two changes against the current apply.go rather than resolving its diff.

The test trap, recorded because it cost three attempts on #80

caf32d7's regression test passed with the fix removed, twice:

  • first version: the fixture's tool content was plain prose, which format leaves alone;
  • second: the content was already-compact JSON, hitting format's already_compact gate.

It only became real with indented JSON, plus an assertion of the precondition — that some case
actually carried a rewritten tool_result through a count change — so it fails loudly instead of
passing vacuously. Any new reproduction should assert that precondition too.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions