Skip to content

fix(rehydration): restore Anthropic tool calls, and escape values on the JSON path - #33

Merged
ninadphalak merged 2 commits into
mainfrom
fix/anthropic-tool-rehydration
Sep 13, 2026
Merged

ninadphalak merged 2 commits into
mainfrom
fix/anthropic-tool-rehydration

Conversation

@ninadphalak

Copy link
Copy Markdown
Owner

Why

1.6.2 (#32) closed the reversible-redaction gap for OpenAI-shaped tool calls and left it open for Anthropic-native ones. That is half a guarantee: an Anthropic tool call still reached the caller carrying [EMAIL_1], on every request, not as an edge case.

OpenAI Anthropic
Tool args, non-streaming ✅ 1.6.2 — but unescaped ❌ not restored at all
Tool args, streaming ✅ 1.6.2 ❌ not restored at all

This closes all three cells.

1. Anthropic non-streaming tool_use.input was skipped whole

_rehydrate_json_response walked content[] looking for a text key. A tool_use block has none, so it was skipped entirely and the caller invoked its tool with a placeholder.

input is a decoded object, not JSON text, so _rehydrate_decoded_leaves restores the string leaves in place (depth-bounded) and the serializer escapes them on the way out — no escaping dance needed here, unlike the OpenAI arguments string.

2. Anthropic streamed tool input was never handled

grep input_json_delta matched nothing in the tree. Anthropic carries tool arguments as partial_json fragments on a content block — the shape OpenAI carries as function.arguments.

The stream translation now:

  • opens the call from content_block_start, carrying its id and name once, as an OpenAI-shaped client needs
  • restores each fragment through that block's own window, reusing the per-channel keying and the JSON-escaping vault from 1.6.2
  • drains the window before content_block_stop, never after

That last point is the Anthropic counterpart of the finish_reason rule: the stop event is where the call finishes growing, so a tail held past it is parsed against a truncated document.

Index translation. Anthropic numbers tool blocks in the same space as text blocks, so a reply whose first block is prose puts its tool call at index 1. Emitting that verbatim would open a tool call at index 1 with no index 0 in front of it, which clients concatenating by index handle badly. Ordinals are handed out densely from 0 in order of first sight.

3. Non-streaming OpenAI arguments was not escaped

1.6.2 fixed this for the stream and left the same reply behaving differently depending on whether it was streamed — an inconsistency that change introduced. json_escaped_vault is now exported from the streaming module, and _rehydrate_json_response builds one per response so both paths agree.

Testing

10 new tests. 9 fail against 1.6.2 — verified by reverting llm_shield_proxy/ and re-running with the tests in place. The tenth is a deliberate no-regression guard (a value needing no escaping round-trips byte-identical) and passes either way on purpose.

tests/test_response_rehydration.py is new: _rehydrate_json_response had no test coverage at all before this.

Full suite: 979 passed. The two conformance failures in that run are pre-existing on clean main, fail identically without this patch, and pass in isolation — order-dependent flakes, unrelated. ruff check clean.

Still open after this

JSON-RPC traffic was never affected — it routes to the stateless v3 engine, whose lexer is field-agnostic.

…the JSON path

1.6.2 closed the reversible-redaction gap for OpenAI-shaped tool calls and left it
open for Anthropic-native ones, which is half a guarantee. Three gaps, one per
path that a restored value can travel.

Anthropic non-streaming `tool_use` blocks were never rehydrated.
`_rehydrate_json_response` walked `content[]` looking for a `text` key, and a
tool_use block has none, so the block was skipped whole and the caller received a
placeholder as a tool argument. Its `input` is a decoded object rather than JSON
text, so `_rehydrate_decoded_leaves` restores the string leaves in place, bounded
by depth, and the serializer escapes them on the way out.

Anthropic streamed tool input was never rehydrated. `grep input_json_delta`
matched nothing in the tree. Anthropic carries tool arguments as `partial_json`
fragments on a content block -- the shape OpenAI carries as
`function.arguments` -- so the stream translation now opens the call from
`content_block_start` (carrying its id and name once, as an OpenAI client needs),
restores each fragment through that block's own window, and drains the window
BEFORE `content_block_stop`. That ordering is the Anthropic counterpart of the
`finish_reason` rule: the stop event is where the call finishes growing, so a
tail held past it is parsed against a truncated document.

Anthropic numbers tool blocks in the same space as text blocks, so a reply whose
first block is prose puts its tool call at index 1. Emitting that verbatim would
open a call at index 1 with no index 0 in front of it, so ordinals are handed out
densely from 0 in order of first sight.

Restored values on the non-streaming OpenAI path were not escaped into
`arguments`. 1.6.2 fixed this for the stream and left the same reply behaving
differently depending on whether it was streamed. `json_escaped_vault` is now
exported from the streaming module and `_rehydrate_json_response` builds one per
response, so both paths agree.

Ten new tests. Nine fail against 1.6.2; the tenth is a no-regression guard that a
value needing no escaping still round-trips byte-identical, and passes either way
on purpose.
@greptile-apps

greptile-apps Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because a sufficiently deep valid Anthropic tool input can still reach the caller with unresolved placeholders.

Findings

  1. P1 Deep tool inputs remain placeholders ▶

Summary

  • Rehydrates decoded Anthropic tool_use.input values in non-streaming responses.
  • Translates and rehydrates Anthropic streamed tool calls with dense OpenAI tool-call ordinals.
  • Correctly frames buffered tool-call tails as independent SSE events.
  • Adds focused non-streaming and streaming regression coverage.

Diagram

sequenceDiagram
    participant A as Anthropic stream
    participant P as Rehydration proxy
    participant C as OpenAI-shaped client
    A->>P: content_block_start(tool_use)
    P->>C: tool call opener (dense ordinal, id, name)
    A->>P: input_json_delta(partial_json)
    P->>P: Buffer and restore escaped fragments
    P->>C: tool argument fragments
    A->>P: content_block_stop
    P->>C: buffered tail as separate SSE event
    P->>C: content_block_stop
Loading

Reviews (2) · Last reviewed commit: "fix(streaming): frame a buffered flush a..."

Comment thread llm_shield_proxy/streaming/streaming.py

Bounded rather than trusting the payload's own nesting to terminate.
"""
if depth > _MAX_TOOL_INPUT_DEPTH:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deep tool inputs remain placeholders

For a valid Anthropic tool input with a string leaf at depth 9 or deeper, this cutoff returns the nested node without visiting that leaf. If the value contains a vault token repeated from the redacted prompt, the caller receives the placeholder instead of the original value and may invoke the tool with an incorrect argument.

… truncating at 8

Two findings from review on the previous commit, both real.

A flushed Anthropic tool tail was emitted with a single newline, so it shared the
upstream's blank line with the `content_block_stop` that followed it. That is one
SSE event carrying two newline-joined JSON documents: no compliant client can
parse it, and the restored tail is lost -- the failure this change exists to
prevent, reintroduced one layer down in the framing. It now terminates itself.

The existing tests could not see it. They read the stream line by line, which is
exactly the reading that makes a framing bug invisible, so the new one splits on
blank lines the way a client does and asserts every event carries at most one
`data:` line and that each payload parses on its own.

`_MAX_TOOL_INPUT_DEPTH` was 8, which sits inside the range a legitimate tool input
can occupy. Past it the walk returned the node untouched, so a string leaf at
depth 9 kept its placeholder and the caller had no way to tell that from a value
-- a quiet instance of the bug the walk exists to fix. Raised to 40, matching the
ceiling `NonStreamingRehydrator` already applies to decoded payloads, and reaching
it now logs. The log names no node content; invariant 4 holds in log records too.

Two tests, both verified to fail against the previous commit.
@ninadphalak

Copy link
Copy Markdown
Owner Author

Both findings fixed in 1070e43, with a test each that fails against 05abed2.

Malformed SSE event boundary — correct, and worse than a formatting nit: the flush shared the upstream's blank line with the stop event, so the two arrived as one event carrying two newline-joined JSON documents and the restored tail was lost. That is the failure this PR exists to prevent, reintroduced one layer down in the framing. The pre-line now terminates itself.

Worth noting why my tests missed it: they read the stream line by line, which is exactly the reading that makes a framing bug invisible. The new test splits on blank lines the way a client does, asserts every event carries at most one data: line, and parses each payload on its own.

Deep tool inputs remain placeholders — also correct. 8 sits inside the range a legitimate tool input can occupy, and past it the walk returned the node untouched, so a leaf at depth 9 kept its placeholder with no signal. Raised to 40, matching the ceiling NonStreamingRehydrator already applies to decoded payloads, and reaching it now logs — naming no node content, so invariant 4 holds in log records too. Test nests 12 deep and asserts no placeholder survives.

@greptileai

@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

Re-reviewed 1070e43 against both findings. The buffered Anthropic tail now emits as a complete, separate SSE event before content_block_stop, and _rehydrate_decoded_leaves now covers the tested nesting depth while logging when the safety bound is reached. The added regression tests exercise both failure modes. No further blocking findings.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@ninadphalak

Copy link
Copy Markdown
Owner Author

The remaining finding describes the previous commit. _MAX_TOOL_INPUT_DEPTH is 40 as of 1070e43, not 8, so "a string leaf at depth 9 or deeper" is no longer the boundary. Measured against the current code:

depth  9: placeholder survives = False
depth 20: placeholder survives = False
depth 39: placeholder survives = False
depth 41: placeholder survives = True   <- the deliberate bound, and it logs
depth 60: placeholder survives = True   <- same

tests/test_response_rehydration.py::test_deeply_nested_tool_input_is_still_rehydrated nests 12 deep and fails against 05abed2.

The residual truth in the finding is that any bound leaves something unrestored below it, and that is intentional here: it stops a crafted reply becoming an unbounded walk, it matches the ceiling NonStreamingRehydrator already applies to decoded payloads, and past it the truncation is logged rather than silent — which is what makes it a declared limit instead of the quiet failure the 8 was. Removing the bound entirely would trade a visible, absurdly-high ceiling for an unbounded recursion on attacker-shaped input.

@greptileai

@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

You're right—the previous finding was based on the stale depth-8 implementation. With _MAX_TOOL_INPUT_DEPTH = 40, the 12-level regression test is within the supported range, and the remaining boundary is deliberate: depths beyond 40 are bounded, logged, and consistent with the decoded-payload ceiling used by NonStreamingRehydrator. Removing the bound would introduce an unbounded walk on attacker-controlled input. I withdraw the finding; there is no actionable issue here.

@ninadphalak
ninadphalak merged commit ef1b669 into main Sep 13, 2026
16 checks passed
@ninadphalak
ninadphalak deleted the fix/anthropic-tool-rehydration branch September 13, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant