hermes-agent/tests/agent/test_anthropic_output_field_leak.py
RaumfahrerSpiffy 529bb1c3d5 fix(anthropic): strip output-only SDK fields from replayed content blocks
HTTP 400 "messages.N.content.M.text.parsed_output: Extra inputs are not
permitted" on the native Anthropic transport. Anthropic SDK 0.87.0 response
blocks carry output-only attributes the Messages *input* schema forbids: text
blocks get `parsed_output` and `citations=None`, tool_use blocks get `caller`.
normalize_response captured blocks verbatim via _to_plain_data and replayed
them as request input on the next turn, so the forbidden fields leaked back ->
400. Like the earlier thinking-block bug, one poisoned turn wedges every
subsequent request in the session (even the diagnostic turn), recoverable only
by switching models or deleting the session.

This is a defect in the anthropic_content_blocks channel added for the
interleaved-thinking fix: it preserved block ORDER correctly but copied every
SDK attribute, including output-only ones.

Fix — whitelist input-permitted fields per block type at all three leak points:
- agent/transports/anthropic.py normalize_response: sanitize at CAPTURE so the
  poison never persists to state.db (defence-in-depth).
- agent/anthropic_adapter.py _sanitize_replay_block (new): whitelist used on the
  ordered-blocks replay path; also recovers already-poisoned stored sessions.
- agent/anthropic_adapter.py _convert_content_part_to_anthropic: a stored
  `text` part is rebuilt from whitelisted fields instead of dict(part) verbatim
  (this was the exact content.N.text.parsed_output failure locus).

Whitelist not blacklist, so future SDK output-only fields can't reintroduce it.
Block order and thinking-block signatures are preserved (the reason the channel
exists). Adds tests/agent/test_anthropic_output_field_leak.py; full adapter
suite green (163 tests). Existing poisoned state.db rows scrubbed out-of-band.
2026-06-10 20:45:16 -07:00

96 lines
3.9 KiB
Python

"""Regression: output-only SDK fields must not leak into Anthropic request input.
Reproduces HTTP 400 `messages.N.content.M.text.parsed_output: Extra inputs are
not permitted`. Anthropic SDK response blocks carry output-only attributes
(text blocks: `parsed_output`, `citations=None`; tool_use blocks: `caller`)
that the Messages *input* schema forbids. normalize_response captured blocks
verbatim via _to_plain_data and replayed them as input → 400.
Fix: whitelist input-permitted fields per block type at three points —
normalize_response capture, _sanitize_replay_block (ordered-blocks replay), and
_convert_content_part_to_anthropic (content-list replay).
"""
import sys, os
sys.path.insert(0, os.path.expanduser("~/.hermes/hermes-agent"))
import pytest
from agent.anthropic_adapter import (
_sanitize_replay_block,
_convert_content_part_to_anthropic,
_convert_assistant_message,
)
FORBIDDEN = {"parsed_output", "caller"}
def _assert_clean(block):
"""No forbidden output-only key, and no null citations, anywhere."""
assert isinstance(block, dict)
for k in FORBIDDEN:
assert k not in block, f"forbidden field {k!r} survived: {block}"
if "citations" in block:
assert isinstance(block["citations"], list) and block["citations"], \
"citations must be a non-empty list if present (None/[] is input-invalid)"
class TestSanitizeReplayBlock:
def test_text_block_strips_parsed_output_and_null_citations(self):
poisoned = {"type": "text", "text": "hi", "parsed_output": None, "citations": None}
out = _sanitize_replay_block(poisoned)
_assert_clean(out)
assert out == {"type": "text", "text": "hi"}
def test_tool_use_strips_caller(self):
poisoned = {"type": "tool_use", "id": "toolu_1", "name": "read_file",
"input": {"path": "a"}, "caller": {"type": "agent"}}
out = _sanitize_replay_block(poisoned)
_assert_clean(out)
assert out["name"] == "read_file" and out["input"] == {"path": "a"}
def test_thinking_preserves_signature(self):
b = {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
out = _sanitize_replay_block(b)
assert out == {"type": "thinking", "thinking": "x", "signature": "sig-AAA"}
def test_text_keeps_real_citations(self):
real = [{"type": "char_location", "cited_text": "q"}]
out = _sanitize_replay_block({"type": "text", "text": "t", "citations": real})
assert out["citations"] == real
def test_unknown_type_dropped(self):
assert _sanitize_replay_block({"type": "server_tool_use", "foo": 1}) is None
class TestContentPartConversion:
def test_stored_text_block_with_parsed_output_cleaned(self):
# The exact content.N.text.parsed_output failure shape.
part = {"type": "text", "text": "hello", "parsed_output": None, "citations": None}
out = _convert_content_part_to_anthropic(part)
_assert_clean(out)
class TestAssistantReplay:
def test_interleaved_blocks_replayed_clean_and_ordered(self):
m = {
"role": "assistant",
"anthropic_content_blocks": [
{"type": "thinking", "thinking": "plan", "signature": "s1"},
{"type": "text", "text": "doing it", "parsed_output": None, "citations": None},
{"type": "tool_use", "id": "toolu_1", "name": "read_file",
"input": {"path": "a"}, "caller": {"type": "agent"}},
],
}
out = _convert_assistant_message(m)
blocks = out["content"]
# order preserved
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
# every block clean
for b in blocks:
_assert_clean(b)
# signature + tool fields intact
assert blocks[0]["signature"] == "s1"
assert blocks[2]["name"] == "read_file"
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))