From 7a1eed8268a7cb9112c8e4a29c868009d7137315 Mon Sep 17 00:00:00 2001 From: RaumfahrerSpiffy <56406949+RaumfahrerSpiffy@users.noreply.github.com> Date: Sun, 31 May 2026 21:45:18 -0400 Subject: [PATCH] fix(anthropic): redact replayed tool inputs and broaden thinking-replay 400 recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive hardening changes on the interleaved-thinking replay path introduced by this PR's anthropic_content_blocks channel. Both are scoped to that channel's blast radius; neither changes correct behavior. 1. Replay-time tool-input re-sourcing (credential safety). The ordered-block channel captures each tool_use `input` from the RAW API response in normalize_response, which is NOT credential-redacted. The parallel tool_calls[].function.arguments IS redacted at storage time (build_assistant_message, #19798). The verbatim-replay fast path in _convert_assistant_message replayed the raw block input, so a secret a model inlined into a tool call (e.g. an Authorization header value passed inside a terminal command) would ride back onto the wire even though it is redacted everywhere else in history. Re-source tool_use input from the redacted tool_calls map by sanitized id; interleave order (the reason this channel exists) is unaffected. Adapted from #36071, which re-sources tool inputs the same way on its replay path. 2. Broaden the thinking-replay 400 classifier (defense-in-depth). error_classifier only matched "signature" + "thinking", so the frozen-block variant — "thinking ... blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response." — carried no "signature" token and fell through to a non-retryable abort. The anthropic_content_blocks channel prevents the reorder that triggers this 400 at the source, but if any future mutator reintroduces it, the turn now self-heals via the existing strip-reasoning-and-retry recovery instead of crash-looping. A negative case ensures an unrelated "cannot be modified" 400 (no "thinking") is not swept in. Mirrors the classifier broadening in #36087 and #36071. Tests - tests/agent/test_anthropic_thinking_block_order.py: a replay test asserting an inlined secret is redacted on the wire while interleave order is preserved. - tests/agent/test_error_classifier.py: three cases — frozen-block 400 native and via OpenRouter route to thinking_signature/retryable; an unrelated "cannot be modified" 400 does not. Both grafts verified RED (tests fail with the change reverted) then GREEN. Full adapter, transport, classifier and output-field-leak suites pass. Co-authored-by: AlexanderBFoley <92330381+AlexanderBFoley@users.noreply.github.com> --- agent/anthropic_adapter.py | 34 ++++++- .../test_anthropic_thinking_block_order.py | 99 +++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index a95fd2f10f4f..8476ef67f57f 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1764,11 +1764,41 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: # dropped, leaving thinking signatures and tool_use id/name/input intact. ordered_blocks = m.get("anthropic_content_blocks") if isinstance(ordered_blocks, list) and ordered_blocks: + # Re-source each tool_use input from the stored tool_calls map rather + # than the captured block. The ordered-blocks list captures tool_use + # input from the RAW API response (normalize_response), which is NOT + # credential-redacted; tool_calls[].function.arguments IS redacted at + # storage time (build_assistant_message, #19798). Replaying the raw + # block input would resurrect a secret the model inlined into a tool + # call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'") + # onto the wire, even though the same value is redacted everywhere else + # in history. Keying by sanitized tool id preserves interleave order + # (the reason this channel exists) while swapping in the redacted + # input. Adapted from #36071 (replay-time tool-input re-sourcing). + redacted_input_by_id: Dict[str, Any] = {} + for tc in m.get("tool_calls", []) or []: + if not isinstance(tc, dict): + continue + fn = tc.get("function", {}) or {} + raw_args = fn.get("arguments", "{}") + try: + parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except (json.JSONDecodeError, ValueError): + parsed_args = {} + redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args replayed: List[Dict[str, Any]] = [] for b in ordered_blocks: clean = _sanitize_replay_block(b) - if clean is not None: - replayed.append(clean) + if clean is None: + continue + if clean.get("type") == "tool_use": + # Override raw (un-redacted) input with the redacted copy when + # we have one for this id; fall back to the sanitized block + # input only if the tool_call is missing (shape mismatch). + redacted = redacted_input_by_id.get(clean.get("id", "")) + if redacted is not None: + clean["input"] = redacted + replayed.append(clean) if replayed: return {"role": "assistant", "content": replayed} diff --git a/tests/agent/test_anthropic_thinking_block_order.py b/tests/agent/test_anthropic_thinking_block_order.py index a02c5a9eb77e..3d090983f006 100644 --- a/tests/agent/test_anthropic_thinking_block_order.py +++ b/tests/agent/test_anthropic_thinking_block_order.py @@ -231,5 +231,104 @@ class TestInterleavedThinkingBlockOrder: ) +class TestInterleavedReplayCredentialRedaction: + """The verbatim-replay fast path must not leak un-redacted secrets. + + anthropic_content_blocks captures each tool_use ``input`` from the RAW API + response (normalize_response), which is NOT credential-redacted. The + parallel tool_calls[].function.arguments IS redacted at storage time + (build_assistant_message, #19798). If the fast path replays the block's raw + input verbatim, a secret the model inlined into a tool call rides back onto + the wire — even though it is redacted everywhere else in history. The fix + re-sources tool_use input from the redacted tool_calls map by id. + """ + + def test_tool_use_input_resourced_from_redacted_tool_calls(self): + REDACTED = "[REDACTED_SECRET]" + # Ordered channel: raw input carries the live secret (as captured from + # the unredacted API response). + ordered = [ + {"type": "thinking", "thinking": "Call the API.", "signature": "sig-AAA"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "terminal", + "input": {"command": "curl -H 'Authorization: Bearer sk-LIVE-SECRET-123'"}, + }, + {"type": "thinking", "thinking": "Now the second call.", "signature": "sig-BBB"}, + { + "type": "tool_use", + "id": "toolu_2", + "name": "terminal", + "input": {"command": "echo done"}, + }, + ] + # Stored tool_calls: arguments already redacted (the #19798 path). + assistant_msg = { + "role": "assistant", + "content": "", + "reasoning_details": [b for b in ordered if b["type"] == "thinking"], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": { + "name": "terminal", + "arguments": json.dumps( + {"command": f"curl -H 'Authorization: Bearer {REDACTED}'"} + ), + }, + }, + { + "id": "toolu_2", + "type": "function", + "function": { + "name": "terminal", + "arguments": json.dumps({"command": "echo done"}), + }, + }, + ], + "anthropic_content_blocks": ordered, + } + messages = [ + {"role": "user", "content": "Hit the API twice."}, + assistant_msg, + {"role": "tool", "tool_call_id": "toolu_1", "content": "200 OK"}, + {"role": "tool", "tool_call_id": "toolu_2", "content": "done"}, + ] + + _system, anthropic_messages = convert_messages_to_anthropic( + messages, base_url=None, model="claude-opus-4-8", + ) + assistant_out = [m for m in anthropic_messages if m.get("role") == "assistant"] + assert assistant_out, "no assistant message in converted output" + blocks = assistant_out[-1]["content"] + + tool_uses = {b["id"]: b for b in blocks if b.get("type") == "tool_use"} + assert set(tool_uses) == {"toolu_1", "toolu_2"}, "tool_use blocks missing/renamed" + + # The replayed input must be the REDACTED value, not the live secret. + replayed_cmd = tool_uses["toolu_1"]["input"]["command"] + assert "sk-LIVE-SECRET-123" not in replayed_cmd, ( + "Un-redacted secret leaked onto the wire via the verbatim-replay " + "fast path. tool_use input must be re-sourced from the redacted " + "tool_calls map, not the raw captured block." + ) + assert REDACTED in replayed_cmd + + # Interleave order is still preserved (the reason the channel exists). + order = [ + ("thinking", b.get("signature")) if b.get("type") == "thinking" + else ("tool_use", b.get("id")) + for b in blocks if b.get("type") in ("thinking", "tool_use") + ] + assert order == [ + ("thinking", "sig-AAA"), + ("tool_use", "toolu_1"), + ("thinking", "sig-BBB"), + ("tool_use", "toolu_2"), + ] + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"]))