fix(guardrails): scan /v1/messages tool traffic - #35999
Merged
mateo-berri merged 2 commits intoAug 6, 2026
Merged
Conversation
Guardrails silently skipped three surfaces on the Anthropic Messages path, so an agent loop driven by /v1/messages ran unguarded: - The Anthropic input translation never walked tool_result blocks, so content returned by a local tool (a curl, a file read, an MCP call) reached the model unscanned in both the string and list content shapes, images inside a tool_result included. - tool_permission only understood ModelResponse, so an Anthropic non-streaming response or a raw SSE stream carrying tool_use blocks passed through with no rule ever evaluated. - ContentFilterGuardrail scanned inputs["texts"] but never inputs["tool_calls"], so the arguments a model proposes for a tool call went unchecked. Tool call arguments are parsed as JSON before filtering so a MASK action rewrites the value and leaves the payload valid JSON; non-JSON arguments fall back to scanning the raw string. Denied tool_use blocks are dropped from the Anthropic content array and replaced with a text block, and stop_reason resets to end_turn when nothing tool-shaped survives.
Contributor
Greptile SummaryThis PR extends guardrail coverage across Anthropic tool results, tool-call arguments, and raw streaming responses
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains; the previously reported raw-stream bypass and stale tool-use termination behavior are both addressed at the current head
|
| Filename | Overview |
|---|---|
| litellm/llms/anthropic/chat/guardrail_translation/handler.py | Adds typed extraction and exact write-back targets for Anthropic tool-result text and nested images |
| litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py | Reuses recursive argument filtering to inspect and safely rewrite tool-call arguments |
| litellm/proxy/guardrails/guardrail_hooks/tool_permission.py | Adds Anthropic response and SSE permission handling, fail-closed raw-stream validation, and corrected termination metadata |
| tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py | Covers both tool-result wire shapes, aligned write-back, nested images, and skip behavior |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py | Covers blocked, allowed, masked, nested, and non-JSON tool-call arguments |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py | Covers Anthropic block and rewrite behavior, SSE reconstruction, split boundaries, and fail-closed handling |
Reviews (2): Last reviewed commit: "fix(tool_permission): fail closed on unv..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
… the turn when every tool call is denied An SSE stream that cannot be positively identified as Anthropic (no parseable message_start event) now blocks instead of passing through unscanned, closing the bypass where any raw-SSE backend skipped tool permission checks entirely. Buffered chunks are joined back into one stream before parsing, so events split across network chunk boundaries assemble correctly instead of being silently dropped. Rewrite mode now resets finish_reason to stop when no tool call survives, so the re-encoded Anthropic stream reports stop_reason end_turn and clients do not wait for a tool result that never comes
Contributor
Author
yucheng-berri
approved these changes
Aug 5, 2026
Merged
5 tasks
Base automatically changed from
litellm_content_filter_post_mcp_call
to
litellm_internal_staging
August 6, 2026 01:08
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
/v1/messagestool_result content was never scannedtool_permissionignored Anthropic responses and SSE streamstool_permission500s on every/v1/messagesstreamstop_reason: tool_useHow it solves it:
tool_permissionthe Anthropic response and SSE shapesinputs["tool_calls"]arguments as parsed JSONstream_chunk_builderwhen chunks are raw SSEmessage_starteventstop_reasontoend_turnwhen every tool is deniedRelevant issues
Linear ticket
Resolves LIT-5251
Resolves LIT-5250
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Stacked on #35980, so the "before" hash below is that PR's head rather than staging
Live proxy, real Anthropic calls,
claude-sonnet-5, no mocks. Config used for every run:Three request bodies, one per gap.
gap1_tool_result.jsonreplays a poisoned tool result:Before, at
83aca91dde(#35980 head)Every guardrail is silent. The poisoned tool result reaches the model (it happens to self-refuse here, which is exactly the point: nothing but the model's own judgement stopped it), the denied
run_shellcomes back untouched, andrm -rf /sails through the pattern. Every streaming request 500s, becausetool_permissionhands raw SSE bytes tostream_chunk_builder:After, at
f16f3e23cd(this PR's head)All six block, the streaming 500 is gone, and the streamed deny stays on the rule message across repeated runs (5x) even when the network splits an SSE event across chunk boundaries, which live QA caught happening for real
Rewritten streams end the turn, at
f16f3e23cdSame config except the
tool_permissionguardrail runs withon_disallowed_action: rewrite, samegap2_tool_use_stream.json:No
tool_useblock survives and the rewritten stream closes withstop_reason: end_turninstead oftool_use, so an Anthropic-native client ends the turn instead of waiting forever on a tool resultSame thing through a real agent
Captured at
bee787b4b5; this leg exercises only the content filter, which thef16f3e23cdcommit does not touch. Claude Code v2.1.222 pointed at the gateway withANTHROPIC_BASE_URL=http://127.0.0.1:4000, running its own local Bash tool, against a content filter with apost_callegress pattern and thepre_callinjection categories. Turn 1 asks for a download from a non-allowlisted host, turn 2 (after/clear) fetches an allowlisted page whose body carries the injection, turn 3 fetches a clean page:No
Ran 1 shell commandline: thecurlwas destroyed in flight on its way back from Anthropic, so it never reached the CLI's tool executor.The allowlisted
curlruns, and the poisoned page body is blocked on the way back in as atool_result. That second block is the one that is impossible without this PRClean traffic is untouched
Type
🐛 Bug Fix
Changes
Anthropic input translation (
litellm/llms/anthropic/chat/guardrail_translation/handler.py) now walkstool_resultblocks. It handles both wire shapes, the string form ("content": "...") and the block-list form ("content": [{"type": "text", ...}]), collects images nested inside a tool result, and writes masked text back to the exact slot it came from. The extraction is a set of pure classmethods returning a frozenExtractedInput, and each scanned string carries a tagged write-back target (MessageContentTarget,ContentBlockTextTarget,ToolResultStringTarget, orToolResultBlockTextTarget) resolved by an exhaustivematchwithassert_never, so a new content shape cannot silently skip its write-back.skip_tool_message_in_guardrailstill suppresses the whole tool-result path.tool_permission(litellm/proxy/guardrails/guardrail_hooks/tool_permission.py) learns the two Anthropic shapes.async_post_call_success_hookbranches onModelResponseversus an Anthropic content array, and on the streaming side_is_raw_sse_streamkeeps raw SSE bytes away fromstream_chunk_builder(the 500 above), routing them through the passthrough handler's assembler instead. The assembler is gated on a positive signal: the buffered chunks are joined back into a single stream before parsing, because httpx cuts at arbitrary byte positions and an SSE event can arrive split mid-JSON across two chunks (live QA hit exactly that), and only a stream with a parseablemessage_startevent is assembled. A raw-SSE stream that cannot be verified as Anthropic raises instead of passing through unscanned, so a foreign or mangled stream fails closed rather than open. Inrewritemode the deniedtool_useblocks are dropped from the content array, a text block carrying the permission errors is appended, and when nothing tool-shaped survivesfinish_reasonresets tostop, which the Anthropic adapter re-encodes asstop_reason: end_turn, so the client does not sit waiting for a tool result that will never come. Allowedtool_useblocks in the same response are preserved.ContentFilterGuardrail(litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py) scansinputs["tool_calls"]arguments. The MCP argument recursion was already there forpre_mcp_call, so it is generalized with acontext_labeland reused rather than duplicated. Arguments are parsed as JSON first, so aMASKaction rewrites the value in place and leaves the payload valid JSON; non-JSON arguments fall back to scanning the raw string, and the existingDEFAULT_MAX_RECURSE_DEPTHcap still fails closed by blocking the call.Tests are regression tests for each gap: five in
test_anthropic_guardrail_handler.pycovering both tool_result shapes, write-back alignment across mixed shapes, nested images, and the skip flag; twelve intest_tool_permission.pycovering non-streaming block, allow, and rewrite (including the one-of-two-denied case), SSE block, passthrough, and rewrite, fail-closed on foreign (Gemini-shaped) and unparseable streams, boundary-split streams still assembling for both the deny and the pass-through leg, and thefinish_reasonreset; five intest_content_filter.pycovering blocked, allowlisted, masked-stays-valid-JSON, nested, and non-JSON arguments. Checked against83aca91dde: 4 of 5, 6 of 7, and 4 of 5 respectively fail without the fix. The six added or extended atf16f3e23cdwere checked againstbee787b4b5, where 5 of 6 fail (the sixth pins the fail-closed gate against over-blocking a genuine stream that arrives split)Final Attestation