feat(guardrails): add scan_only_tool_results to scope unified guardrails to tool results - #36014
Merged
mateo-berri merged 8 commits intoAug 6, 2026
Merged
Conversation
…ils to tool results
Contributor
Greptile SummaryThe PR adds an opt-in guardrail scope that scans only tool results while preserving the rest of the request.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the prior tool-preservation, synthesized-tool, duplicate-name, and tool-schema scoping findings are addressed by the current merge and filtering logic.
|
| Filename | Overview |
|---|---|
| litellm/llms/base_llm/guardrail_translation/utils.py | Adds shared message-scoping, positional write-back, tool-name extraction, and request-preserving tool merge helpers; the previously reported duplicate-name issue is fixed. |
| litellm/llms/openai/chat/guardrail_translation/handler.py | Restricts guardrail inputs to tool results when enabled and safely merges synthesized tools without replacing request-defined schemas. |
| litellm/llms/anthropic/chat/guardrail_translation/handler.py | Applies equivalent tool-result scoping to Anthropic requests while preserving full conversations and provider-native request tools. |
| litellm/proxy/guardrails/guardrail_registry.py | Propagates the new setting and rejects configurations whose combined role filters would scan no content. |
| litellm/integrations/custom_guardrail.py | Adds capability hooks for tool-result scanning and guardrails that return complete reconstructed conversations. |
| litellm/types/guardrails.py | Adds the optional scan_only_tool_results guardrail configuration field. |
| tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py | Covers scoped scanning, legacy function results, synthesized-tool preservation, schema collisions, and duplicate returned names. |
| tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py | Covers Anthropic tool-result extraction, write-back alignment, image scoping, short-circuiting, and synthesized-tool preservation. |
| tests/test_litellm/proxy/guardrails/test_guardrail_registry.py | Verifies initialization rejects guardrails and flag combinations that cannot scan tool results. |
Reviews (7): Last reviewed commit: "fix(guardrails): refuse scan_only_tool_r..." | Re-trigger Greptile
Contributor
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 4 · PR risk: 0/10 |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Base automatically changed from
litellm_guardrails_v1_messages_tool_traffic
to
litellm_internal_staging
August 6, 2026 01:08
…s and merge scoped write-backs Gate the OpenAI handler's tools forwarding behind scan_only_tool_results, matching the Anthropic handler, so a tool-results-only scan can no longer evaluate or rewrite trusted function definitions. When a guardrail returns a replacement structured_messages list, substitute the returned messages back into the positions their scoped originals came from instead of installing the scoped list as the whole conversation, so out-of-scope messages (system prompt, prior turns) survive redaction on both the OpenAI and Anthropic paths.
Contributor
Author
yucheng-berri
approved these changes
Aug 6, 2026
…itellm_scan_only_tool_results
…, ratchet lint budgets
Contributor
Author
…and warn on role-filtered no-op scans
Contributor
Author
… and reject role-filtered no-op combos at init
Contributor
Author
Under scan_only_tool_results, legacy OpenAI function-role messages now count as tool results, and duplicate names among guardrail-returned tools keep only the first occurrence. CustomGuardrail.structured_messages_cover_full_request lets CrowdStrike AIDR declare that its writeback already rebuilds the whole conversation, so handlers install it as-is instead of merging it into the full message list a second time and duplicating out-of-scope rows. Lint budget ceilings ratchet down to match the tree
Contributor
Author
Prompt Security drops tool and function rows unless check_tool_results is on, so it now reports scan-only support from that setting and the registry refuses the pairing at boot. Pairing scan_only_tool_results with skip_tool_message_in_guardrail excludes every message, so guardrail initialization now rejects that combination too.
Contributor
Author
5 tasks
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:
How it solves it:
scan_only_tool_results/v1/messagesand/v1/chat/completionsUser Flow
Before: an agent platform admin who wants injection scanning on tool output can only scan whole requests, so trusted scaffolding prompts get blocked too
After: the admin sets one flag and only tool results are scanned, so scaffolding passes while poisoned tool output is still blocked
Relevant issues
Stacked on #35999, which has since merged. Re-cuts the remaining feature from #36000 on top of its tool_result extraction, so #36000 can be closed once this lands
Linear ticket
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
All runs captured at 14d4897 against live Anthropic and OpenAI APIs (
claude-sonnet-5andgpt-4.1, real API calls). Two proxies booted from the same config, differing only in the one new line:Leg 1, the canary phrase inside a user prompt is out of scope, so the request reaches Anthropic:
{"model":"claude-sonnet-5","id":"msg_011CdmDe2SbZLysK6Y4t3uMT","type":"message","role":"assistant","content":[{"type":"text","text":"4"}],"stop_reason":"end_turn", ...}Leg 2, the same phrase inside a
tool_resultblock is still scanned and blocked:{"error":{"message":"400: {'error': 'Content blocked: injection_canary pattern detected', 'pattern': 'injection_canary', 'guardrail_name': 'injection-filter', 'guardrail_mode': 'pre_call'}","type":"None","param":"None","code":"400"}}Legs 3 and 4, the identical two requests against the port 28532 proxy (no flag) both return the same 400 block, confirming the flag is what changes behavior and full-request scanning stays the default
Legs 5 and 6, same pair on the chat completions surface with the flag on. The
toolrole message carrying the phrase is blocked, the user message carrying it is answered:{"error":{"message":"Content blocked: injection_canary pattern detected","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Content blocked: injection_canary pattern detected","pattern":"injection_canary","guardrail_name":"injection-filter","guardrail_mode":"pre_call"}}}{"id":"chatcmpl-9331b5f7-dbb8-4db0-98eb-c6ea541e2e25","created":1786006707,"model":"claude-sonnet-5","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"4","role":"assistant", ...}}], ...}Leg 7, guardrail-synthesized tools now reach the model. A proxy runs a custom guardrail whose
apply_guardrailappends its ownretrieve_compressed_contentfunction tool (the recovery pattern a compression guardrail uses) withscan_only_tool_results: true. The request carries one app tool, a completed tool round trip, and asks the model to list every tool it can see withtool_choice: "none":At 14d4897 the model reports both the app's tool and the synthesized one, at c2998de the synthesized tool was silently dropped:
Leg 8, the no-op combination is refused at boot. A config pairs the Bedrock guardrail's
experimental_use_latest_role_message_only: truewithscan_only_tool_results: true. At 14d4897 startup fails with the config error; at c2998de the same config boots cleanly with zero warnings and every request would scan nothing:Leg 9, legacy function-role results are in scope. The flag-on proxy gains a
gpt-4.1deployment (the deprecatedfunctionrole is OpenAI-native, and gpt-5.6 rejects the role outright), and the request carries the canary inside a function-role result:At 7d74552 the request sails through with HTTP 200 and the model happily summarizes the poisoned result ("The quarterly report states that revenue grew by 12 percent."), because a request with no tool-role rows never invoked the guardrail at all. At 14d4897 the same request is blocked:
{"error":{"message":"Content blocked: injection_canary pattern detected","type":"None","param":"None","code":"400","provider_specific_fields":{"error":"Content blocked: injection_canary pattern detected","pattern":"injection_canary","guardrail_name":"injection-filter","guardrail_mode":"pre_call"}}}Leg 10, duplicate names among guardrail-returned tools are collapsed. Same setup as leg 7 but the guardrail returns two copies of
retrieve_compressed_content. At 7d74552 both copies were forwarded and Anthropic rejected the request; at 14d4897 only the first copy is forwarded and the request succeeds:Leg 11, a guardrail whose own role filter drops tool results is refused the flag at boot. Prompt Security's
check_tool_resultsoption defaults to off, which filters tool and function rows out before its API is ever called:At 28ff7f3 this config boots cleanly and the leg 5 poisoned tool canary comes back HTTP 200 straight from Anthropic, with no Prompt Security call even attempted (the unreachable API base never errors), the silent no-scan in action. At 14d4897 the same config refuses to boot, and flipping
PROMPT_SECURITY_CHECK_TOOL_RESULTS=trueon the same config boots fine since tool results then really are scanned:Leg 12, pairing the flag with
skip_tool_message_in_guardrailis refused too: the skip excludes tool rows and the scope excludes everything else, so nothing could ever be scanned. At 28ff7f3 a Bedrock guardrail config carrying both flags boots and the poisoned tool canary returns HTTP 200 without any guardrail call; at 14d4897 it refuses:Type
🆕 New Feature
Changes
BaseLitellmParamsgainsscan_only_tool_results, andguardrail_registry.initialize_guardrailpropagates it onto the callback next to the existingskip_system_message_in_guardrailandskip_tool_message_in_guardrailparams (the three setattrs are now one loop)Shared scope logic lives in
litellm/llms/base_llm/guardrail_translation/utils.py:effective_scan_only_tool_results_for_guardrail(the flag must be literallyTrue, so a yaml string or placeholder never silently narrows coverage),role_out_of_guardrail_scope,scoped_structured_message_indices, which folds the existing skip filters and the new scope into one index-based helper, andmerge_guardrailed_scoped_messages, which substitutes a guardrail's returned structured messages back into the positions their scoped originals came fromThe OpenAI chat completions handler threads the flag through
_extract_inputsand the structured messages it hands the guardrail, and with the flag on it no longer forwards requesttools(function definitions), matching the Anthropic path. The Anthropic handler builds on #35999's extraction targets: with the flag on, onlytool_resultpayloads (string form, text blocks, and nested images) are extracted, requesttoolsare not forwarded, and structured messages narrow to thetoolrows, while masking write-back keeps targeting the exact tool_result that was scanned. When a request has no tool results the guardrail is not invoked at allWhen a guardrail returns a replacement
structured_messageslist, the redaction style several guardrail integrations use, both handlers now merge the returned rows back into the positions their scoped originals came from instead of installing the scoped list as the whole conversation, so out-of-scope messages like the system prompt and prior turns survive. The same merge also hardens the existing skip-system and skip-tool write-backsThe Responses API translation handler honors none of the scoping params today (including the existing skip flags), so it is unchanged here
The branch also merges litellm_internal_staging back in: the base's typing sweep had removed the
Optionalimport this PR's new field relied on, which broke every CI suite at import, so the field is now written asbool | None. Alongside the merge, the never-called_extract_input_toolsmethod in the Anthropic handler is deleted, the scan flag helper takesobjectinstead ofAny, and the lint budget ceilings ratchet down by what the branch fixedA second review round surfaced two more gaps, both fixed here. Legacy OpenAI
function-role messages are tool results too, sorole_out_of_guardrail_scopenow keeps bothtoolandfunctionrows in scope; before, a conversation whose only tool output used the deprecated role never invoked the guardrail at all. Andmerge_returned_tools_into_request_toolsnow also dedupes the returned list against itself, keeping the first occurrence per name, so a guardrail that hands back the same synthesized tool twice no longer produces a request the provider rejects for duplicate tool namesA third review round closes two more combinations that could still boot a scan-nothing guardrail. Prompt Security's role filter drops tool and function rows unless its
check_tool_resultsoption is on (PROMPT_SECURITY_CHECK_TOOL_RESULTS, default off), yet it inherited the defaultsupports_scan_only_tool_results()of True, so the flag plus its default config booted cleanly and scanned nothing; the guardrail now reports scan-only support from that setting. And pairingscan_only_tool_resultswithskip_tool_message_in_guardrailexcludes every message (the skip removes tool rows, the scope removes everything else), soinitialize_guardrailnow rejects that pairing with its own config errorRunning the full guardrail suite against those fixes exposed a regression this PR would have shipped: CrowdStrike AIDR's write-back already rebuilds the complete conversation itself when either skip filter is active, so the handlers' positional merge was stitching its full list into the full conversation and duplicating every out-of-scope row (a skip-system request came back
[system, system, user]).CustomGuardrail.structured_messages_cover_full_request()(default False) now lets a guardrail declare that contract; CrowdStrike overrides it when either skip flag is set, and both handlers install such a list as-is instead of merging, restoring the base-branch behavior for that pathReview follow-ups from the first round, both fixed rather than just logged. First, with the flag on, a
toolslist a guardrail returns is merged into the request's tools by name viamerge_returned_tools_into_request_toolsin the shared utils: every request tool is kept with its original schema (a returned tool reusing a request tool's name cannot hijack it) and returned tools with new names are appended, so a recovery-style guardrail that synthesizes its own retrieval tool gets it in front of the model without replacing or shadowing user-defined functions. Without the flag the previous full-replace behavior stands, since there the guardrail saw the originals. Second, combinations that can never scan anything are rejected when the guardrail is initialized:CustomGuardrail.supports_scan_only_tool_results()defaults to True, PANW Prisma AIRS overrides it to False (it only ever scans user, system, and developer messages), Bedrock returns False whenexperimental_use_latest_role_message_onlyis set, andinitialize_guardrailraises a ValueError naming the guardrail when the flag is on and support is False, so the misconfiguration fails at boot or guardrail creation instead of silently scanning nothing. The flag is new in this PR, so no existing deployment can hit the new error. The per-request debug warnings from the earlier follow-up remain for runtime visibilityTests:
TestAnthropicMessagesScanOnlyToolResults(scope narrowing with aligned write-back, no-tool-result short circuit, image scoping, structured write-back merging into the full conversation) andTestScanOnlyToolResultson the OpenAI handler (tool-only scanning, flag must be literally True, function definitions kept out of a tool-results-only scan, structured write-back keeping out-of-scope messages). The four feature tests fail at the parent commit f16f3e2 and pass on this branch; the three review-follow-up regression tests fail at 2ba4e91 and pass from d70e109 on. The tools write-back guard tests on both handlers and the PANW and Bedrock no-op warning tests fail at 28a277e and pass from c2998de on. The second follow-up round adds the synthesized-tool merge tests on both handlers (appended without replacing, plus a name-collision test proving the request schema wins) andTestScanOnlyToolResultsInitRefusalon the registry (PANW rejected, Bedrock with latest-role rejected, Bedrock without it accepted); the five that target the new behavior fail at c2998de and pass from 7d74552 on. The third round adds a function-role scanning test and a duplicate-returned-tool-names test on the OpenAI handler, both failing at 7d74552 and passing from 28ff7f3 on, while the existing CrowdStrike skip-system transform test, which the positional merge had silently broken, passes again from 28ff7f3. The fourth round extendsTestScanOnlyToolResultsInitRefusalwith Prompt Security rejected under its default tool filtering, Prompt Security accepted whenPROMPT_SECURITY_CHECK_TOOL_RESULTSis true, and Bedrock rejected whenskip_tool_message_in_guardrailis combined with the flag; the two refusal tests fail at 28ff7f3 and pass from 14d4897 onFinal Attestation