mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
fix(anthropic): redact replayed tool inputs and broaden thinking-replay 400 recovery
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>
This commit is contained in:
parent
529bb1c3d5
commit
7a1eed8268
2 changed files with 131 additions and 2 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue