Skip to content

Commit 709d74c

Browse files
chopratejasTejas Chopraclaude
authored
test(proxy): pin down what Anthropic's thinking signature actually covers (#3135)
## Why #3124 relaxed the signed-thinking lock on the premise that **the signature seals the thinking block, not the request**. Nothing in Anthropic's public docs states the scope, so that premise was inference — and it shipped **on by default**. This measures it instead. ## Result Each test replays a turn holding a real signed thinking block, mutates exactly one part, and asserts the request is still accepted. **Identical on all five models tested** — `sonnet-4-5`, `opus-4-5`, `sonnet-4-6`, `sonnet-5`, `opus-5`: | mutation | status | |---|---| | exact replay (control) | 200 | | compress a `tool_result` in a later user message — *what we actually do* | 200 | | rewrite sibling `text`/`tool_use` blocks **inside the assistant message holding the thinking block** | 200 | | rewrite top-level `system` + tool descriptions (schema compaction, tool-search deferral) | 200 | | re-serialize the body with reordered keys (canonical encode) | 200 | | **forge the signature** | **400** invalid signature in thinking block | ## The two tests that matter **The sibling case** is the gap the fingerprint cannot close by inspection. `thinking_blocks_survived_mutation` proves the thinking blocks are byte-identical, but says nothing about their *neighbours in the same assistant message*. If the seal covered the whole assistant turn, a compressed sibling would break it and the fingerprint would wave it through. It doesn't. **The forged-signature test is the negative control**, and the load-bearing test in the file. Without it, a wall of green would be equally consistent with *"Anthropic never validates signatures on this request shape"* — which would make every other assertion here vacuous. It 400s, so validation is live and the acceptances carry information. This also disproves #2254's stated cause directly: a plain canonical re-encode changes the bytes and is accepted. Those 400s were real, but were never traced to their true trigger. ## Scope - Gated behind `pytest.mark.live`, skipped without a key. Verified it skips cleanly (`6 skipped`) and deselects under `-m "not live"`, so CI is unaffected. - Model override via `HEADROOM_LIVE_THINKING_MODEL`. - Also replaces the speculative risk note in `body_forwarding.py` with the measured finding. The relaxation still only forwards when every thinking block is byte-identical — narrower than this evidence permits — so these results are headroom, not the safety margin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 284ff31 commit 709d74c

2 files changed

Lines changed: 255 additions & 14 deletions

File tree

headroom/proxy/body_forwarding.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,21 +157,30 @@ def _original_body_has_signed_thinking_blocks(original_body_bytes: bytes | None)
157157
#: thinking block is provably unchanged. **Default ON**; set this to ``0`` (or
158158
#: ``false``/``no``/``off``) to restore the previous blanket lock.
159159
#:
160-
#: Risk note, recorded deliberately. The lock this relaxes was added by #2254
161-
#: after real upstream 400s ("`thinking` blocks ... cannot be modified"). That
162-
#: report attributed the failure to a plain canonical re-encode, which cannot
163-
#: alter parsed values and therefore cannot by itself invalidate a signature --
164-
#: and the report's own log shows a transform (`tool_search_deferral`) firing on
165-
#: the failing turn. So the stated cause does not hold up, but the failure was
166-
#: real and its true trigger was never isolated. This relaxation is narrower
167-
#: than what broke: it forwards edits ONLY when every thinking block is
168-
#: byte-identical to the client's, which is exactly the property #2254's blanket
169-
#: rule was a crude proxy for.
160+
#: The lock this relaxes was added by #2254 after real upstream 400s
161+
#: ("`thinking` blocks ... cannot be modified").
170162
#:
171-
#: It is on by default at the maintainer's direction, to recover the savings the
172-
#: lock was discarding. If Anthropic starts rejecting thinking-bearing turns,
173-
#: set the env var to ``0`` -- that is a single-variable, no-deploy rollback to
174-
#: the previous behaviour, and the 400s stop immediately.
163+
#: What the signature actually covers is now measured, not assumed --
164+
#: ``tests/test_thinking_signature_scope_live.py`` pins it against the live API
165+
#: on sonnet-4-5, opus-4-5, sonnet-4-6, sonnet-5 and opus-5, with identical
166+
#: results on all five. Anthropic accepts a replayed turn whose signed thinking
167+
#: block is intact while we compress a ``tool_result``, rewrite sibling
168+
#: ``text``/``tool_use`` blocks *inside the same assistant message*, rewrite
169+
#: top-level ``system``/``tools``, or re-serialize the whole body with reordered
170+
#: keys. It rejects exactly one thing: a forged ``signature`` ("Invalid
171+
#: `signature` in `thinking` block"), which is the negative control proving the
172+
#: endpoint validates signatures on this shape at all -- without it every
173+
#: acceptance above would be vacuous.
174+
#:
175+
#: So the seal binds the block, not the request, and #2254's stated cause (a
176+
#: plain canonical re-encode) is disproven directly: variant F changes the bytes
177+
#: and is accepted. The 400s were real but were never traced to their true
178+
#: trigger. This relaxation stays narrower than the evidence permits anyway --
179+
#: it forwards edits ONLY when every thinking block is byte-identical to the
180+
#: client's -- so the measurements above are headroom, not the safety margin.
181+
#:
182+
#: If Anthropic ever changes this, that live test fails loudly, and setting the
183+
#: env var to ``0`` is a single-variable, no-deploy rollback to the blanket lock.
175184
THINKING_PRESERVING_MUTATIONS_ENV = "HEADROOM_THINKING_PRESERVING_MUTATIONS"
176185

177186

@@ -238,6 +247,10 @@ def thinking_blocks_survived_mutation(
238247
body is a category error -- it protects bytes the signature says nothing
239248
about, and on Claude Code traffic that is nearly the whole request.
240249
250+
That scope claim is measured against the live API, not inferred -- see
251+
``tests/test_thinking_signature_scope_live.py``, which also pins the one
252+
thing Anthropic does reject (a forged ``signature``).
253+
241254
Conservative by construction: any parse failure, or any detectable
242255
difference at all, returns False and the caller keeps today's passthrough.
243256
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Live probe: what does Anthropic's thinking-block ``signature`` actually cover?
2+
3+
This test is the empirical foundation for the #3124 relaxation. That change lets
4+
Headroom forward its compression edits on a request that carries signed thinking
5+
blocks, instead of discarding every edit (the #2254 blanket lock, which cost
6+
~34% of Claude Code requests all of their savings). It is only correct if the
7+
signature seals *the thinking block*, not the surrounding request.
8+
9+
Nothing in Anthropic's public docs states the scope, so it is pinned here by
10+
experiment. Each test mutates exactly one part of a replayed turn that holds a
11+
real signed thinking block and asserts the request is still accepted.
12+
13+
``test_forged_signature_is_rejected`` is the **negative control** and the most
14+
important test in the file: without it, a wall of passing tests would be equally
15+
consistent with "Anthropic never validates signatures on this shape", which
16+
would make every other assertion here vacuous.
17+
18+
Opt-in: requires a real key and is gated behind ``pytest.mark.live``.
19+
Run with ``pytest -m live tests/test_thinking_signature_scope_live.py``.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import copy
25+
import json
26+
import os
27+
from typing import Any
28+
29+
import pytest
30+
31+
from tests._dotenv import autouse_apply_env, load_env_overrides
32+
33+
_env = load_env_overrides()
34+
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY") or _env.get("ANTHROPIC_API_KEY", "")
35+
36+
pytestmark = [
37+
pytest.mark.live,
38+
pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set"),
39+
]
40+
41+
apply_dotenv = autouse_apply_env(_env)
42+
43+
MODEL = os.environ.get("HEADROOM_LIVE_THINKING_MODEL", "claude-sonnet-4-6")
44+
45+
TOOLS: list[dict[str, Any]] = [
46+
{
47+
"name": "get_weather",
48+
"description": "Get the current weather in a given location.",
49+
"input_schema": {
50+
"type": "object",
51+
"properties": {"location": {"type": "string", "description": "City name"}},
52+
"required": ["location"],
53+
},
54+
}
55+
]
56+
SYSTEM = "You are a helpful assistant. Use tools when they are relevant."
57+
# Deliberately requires reasoning: on adaptive-thinking models (Claude 5) a
58+
# trivial prompt makes the model skip thinking entirely and the probe has
59+
# nothing to test.
60+
PROMPT = (
61+
"I have 3 meetings in San Francisco tomorrow starting at 9:00am, 1:00pm and "
62+
"4:30pm. Each runs 90 minutes and I need 25 minutes of travel between "
63+
"consecutive meetings. Reason carefully about whether that schedule has any "
64+
"conflicts, then call get_weather for San Francisco so I know what to wear."
65+
)
66+
67+
68+
def _think_cfg(model: str) -> dict[str, Any]:
69+
"""Claude 5 replaced ``budget_tokens`` thinking with adaptive + effort."""
70+
if model in ("claude-opus-5", "claude-sonnet-5", "claude-fable-5"):
71+
return {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}
72+
return {"thinking": {"type": "enabled", "budget_tokens": 2000}}
73+
74+
75+
def _post(body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
76+
import httpx
77+
78+
resp = httpx.post(
79+
"https://api.anthropic.com/v1/messages",
80+
json=body,
81+
headers={
82+
"x-api-key": ANTHROPIC_KEY,
83+
"anthropic-version": "2023-06-01",
84+
"content-type": "application/json",
85+
},
86+
timeout=120.0,
87+
)
88+
return resp.status_code, resp.json()
89+
90+
91+
@pytest.fixture(scope="module")
92+
def signed_turn() -> dict[str, Any]:
93+
"""Obtain one genuine signed thinking block, and the replay body around it."""
94+
think = _think_cfg(MODEL)
95+
status, resp = _post(
96+
{
97+
"model": MODEL,
98+
"max_tokens": 3000,
99+
"system": SYSTEM,
100+
"tools": TOOLS,
101+
**think,
102+
"messages": [{"role": "user", "content": PROMPT}],
103+
}
104+
)
105+
if status != 200:
106+
pytest.skip(f"could not obtain a thinking turn ({status}): {json.dumps(resp)[:200]}")
107+
108+
content = resp["content"]
109+
thinking_idx = next(
110+
(i for i, b in enumerate(content) if b["type"] in ("thinking", "redacted_thinking")),
111+
None,
112+
)
113+
if thinking_idx is None:
114+
pytest.skip(f"{MODEL} returned no thinking block for the probe prompt")
115+
tool_idx = next((i for i, b in enumerate(content) if b["type"] == "tool_use"), None)
116+
if tool_idx is None:
117+
pytest.skip(f"{MODEL} did not call the tool; the replay shape needs a tool_use")
118+
119+
followup = [
120+
{
121+
"role": "user",
122+
"content": [
123+
{
124+
"type": "tool_result",
125+
"tool_use_id": content[tool_idx]["id"],
126+
"content": "62F, foggy, wind 12mph. Forecast: fog clearing by noon.",
127+
}
128+
],
129+
}
130+
]
131+
return {
132+
"thinking_idx": thinking_idx,
133+
"tool_idx": tool_idx,
134+
"signature": content[thinking_idx].get("signature", ""),
135+
"body": {
136+
"model": MODEL,
137+
"max_tokens": 3000,
138+
"system": SYSTEM,
139+
"tools": TOOLS,
140+
**think,
141+
"messages": [
142+
{"role": "user", "content": PROMPT},
143+
{"role": "assistant", "content": content},
144+
]
145+
+ followup,
146+
},
147+
}
148+
149+
150+
def _assistant(body: dict[str, Any]) -> list[dict[str, Any]]:
151+
return body["messages"][1]["content"]
152+
153+
154+
def _expect_accepted(body: dict[str, Any], what: str) -> None:
155+
status, resp = _post(body)
156+
assert status == 200, (
157+
f"Anthropic rejected a request after {what}, so the thinking signature "
158+
f"covers more than the block itself and the #3124 relaxation is unsafe "
159+
f"for this mutation. Response: {json.dumps(resp)[:300]}"
160+
)
161+
162+
163+
def test_exact_replay_is_accepted(signed_turn):
164+
"""Control: the unmodified replay must work, or every other test is noise."""
165+
_expect_accepted(copy.deepcopy(signed_turn["body"]), "no modification at all")
166+
167+
168+
def test_compressing_a_tool_result_is_accepted(signed_turn):
169+
"""The mutation Headroom actually makes on Claude Code traffic."""
170+
body = copy.deepcopy(signed_turn["body"])
171+
block = body["messages"][2]["content"][0]
172+
body["messages"][2]["content"][0] = {**block, "content": "62F foggy"}
173+
_expect_accepted(body, "compressing a tool_result in a later user message")
174+
175+
176+
def test_modifying_a_sibling_block_in_the_thinking_message_is_accepted(signed_turn):
177+
"""The gap the fingerprint cannot close by inspection.
178+
179+
``thinking_blocks_survived_mutation`` proves the thinking blocks are
180+
byte-identical, but says nothing about their siblings in the same assistant
181+
message. If the seal covered the whole assistant turn, a compressed sibling
182+
would break it and the fingerprint would wave it through.
183+
"""
184+
body = copy.deepcopy(signed_turn["body"])
185+
blocks = _assistant(body)
186+
tool_idx = signed_turn["tool_idx"]
187+
blocks[tool_idx] = {**blocks[tool_idx], "input": {"location": "San Francisco, CA"}}
188+
text_idx = next((i for i, b in enumerate(blocks) if b["type"] == "text"), None)
189+
if text_idx is not None:
190+
blocks[text_idx] = {**blocks[text_idx], "text": "compressed sibling text"}
191+
_expect_accepted(body, "modifying sibling blocks inside the thinking message")
192+
193+
194+
def test_modifying_top_level_system_and_tools_is_accepted(signed_turn):
195+
"""Tool-schema compaction and tool-search deferral edit these fields."""
196+
body = copy.deepcopy(signed_turn["body"])
197+
body["system"] = "Assistant. Use tools."
198+
body["tools"] = copy.deepcopy(TOOLS)
199+
body["tools"][0]["description"] = "Weather."
200+
_expect_accepted(body, "rewriting top-level system and tool descriptions")
201+
202+
203+
def test_canonical_reserialization_is_accepted(signed_turn):
204+
"""#2254 blamed a plain re-encode for the 400s. It is not the cause."""
205+
body = copy.deepcopy(signed_turn["body"])
206+
body["messages"][1] = json.loads(
207+
json.dumps({"content": _assistant(body), "role": "assistant"}, sort_keys=True)
208+
)
209+
_expect_accepted(body, "re-serializing the body with reordered keys")
210+
211+
212+
def test_forged_signature_is_rejected(signed_turn):
213+
"""NEGATIVE CONTROL — the load-bearing test in this file.
214+
215+
If a forged signature is *accepted*, Anthropic is not validating signatures
216+
on this request shape at all, and every acceptance above proves nothing.
217+
"""
218+
body = copy.deepcopy(signed_turn["body"])
219+
idx = signed_turn["thinking_idx"]
220+
blocks = _assistant(body)
221+
blocks[idx] = {**blocks[idx], "signature": "A" * len(signed_turn["signature"])}
222+
status, resp = _post(body)
223+
assert status == 400, (
224+
"A forged thinking signature was ACCEPTED. Signature validation is not "
225+
"active on this shape, so the acceptances asserted by the other tests in "
226+
f"this module carry no information. Response: {json.dumps(resp)[:300]}"
227+
)
228+
assert "signature" in json.dumps(resp).lower()

0 commit comments

Comments
 (0)