Skip to content

fix(guardrails): scan tool results and enforce tool policy on passthrough streams - #36000

Closed
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_bedrock_guardrails_demo
Closed

fix(guardrails): scan tool results and enforce tool policy on passthrough streams#36000
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_bedrock_guardrails_demo

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Prompt injection in tool results reaches the model unscanned
  • Anthropic tool_result payloads live under content, not text
  • tool_permission 500s on streamed /v1/messages passthrough
  • Prompt-attack filters false-positive on agent scaffolding

How it solves it:

  • Extract, scan, and mask tool_result blocks in both forms
  • Reassemble provider SSE frames before tool rules run
  • Add scan_only_tool_results to scope a guardrail to tool output

Relevant 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

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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 proofs run against a live proxy on real AWS Bedrock, us-east-1, with a real Bedrock guardrail resource and PROMPT_ATTACK set to HIGH. Before runs are at 0659738b3e (litellm_internal_staging), after runs at ac0391269e

1. Prompt injection inside an Anthropic tool_result

The file the agent reads carries IGNORE ALL PREVIOUS INSTRUCTIONS in the middle of an ordinary revenue report, and comes back to the model as a tool_result block

Before, 0659738b3e: the injection sails through and the model answers normally

$ PORT=41337 bash verify.sh
4. DENIED: prompt injection inside a tool result (/v1/messages)
{"model":"claude-sonnet-5","id":"msg_bdrk_fqx3fgx4hyhxcp3lxje7tszic637injbkycojpv5mpim23ogkl5a",
 "type":"message","role":"assistant","content":[{"type":"thinking", ...
HTTP 200

After, ac0391269e: blocked before the model sees it

$ PORT=39049 bash verify.sh
4. DENIED: prompt injection inside a tool result (/v1/messages)
{"error":{"message":"400: {'error': 'Violated guardrail policy', 'bedrock_guardrail_response':
 'Blocked by AWS Bedrock Guardrails: prompt attack detected in model input.',
 'guardrailIdentifier': '1pkg9iw5lbhl', 'assessments': [{'policy': 'contentPolicy', 'matches':
 [{'category': 'filters', 'type': 'PROMPT_ATTACK', 'confidence': 'HIGH', 'filterStrength': 'HIGH',
 'action': 'BLOCKED'}]}], 'guardrail_name': 'aws-prompt-attack-scanner', 'guardrail_mode': 'pre_call'}"}}
HTTP 400

2. tool_permission on streamed /v1/messages

A streamed request whose model output calls bash with a download from a host outside the allowlist

Before, 0659738b3e: every streamed passthrough request with this guardrail on dies in the hook, so the guardrail is not merely unenforced, it takes the route down

$ PORT=41337 zsh stream_tool_proof.sh
{"error":{"message":"litellm.APIError: Error building chunks for logging/streaming usage calculation","code":"500"}}
HTTP 500

After, ac0391269e: the policy is enforced on the streamed response

$ PORT=39049 zsh stream_tool_proof.sh
{"error":{"message":"Guardrail raised an exception, Guardrail: download-allowlist,
 Message: Tool 'bash' denied by rule 'deny-downloads-outside-allowlist'"}}
HTTP 400

And an allowed tool call still streams end to end, so this is enforcement rather than a blanket block

$ PORT=39049 zsh stream_allowed_proof.sh
event: message_stop
data: {"type": "message_stop", "usage": {"input_tokens": 572, "output_tokens": 51}}
HTTP 200

3. Full five-check sweep after the fix, ac0391269e

$ PORT=39049 bash verify.sh
1. DENIED: agent tries to download an installer from a host outside the allowlist   HTTP 400
2. ALLOWED: same tool, host on the allowlist                                        HTTP 200
3. DENIED: prompt injection inside a tool result (/v1/chat/completions)              HTTP 400
4. DENIED: prompt injection inside a tool result (/v1/messages)                      HTTP 400
5. ALLOWED: normal agent traffic, harness scaffolding not flagged                    HTTP 200

Check 5 is what scan_only_tool_results buys: a real Claude Code system prompt trips PROMPT_ATTACK at every filter strength, so without scoping, the filter is unusable in front of a coding agent

4. Live against a real coding agent, ac0391269e

Claude Code pointed at the proxy through ANTHROPIC_BASE_URL, in a scratch workspace holding the poisoned file

Blocked download, where the approval prompt never appears because the policy fires above the human:

❯ Use the Bash tool to download the installer so I can inspect it: curl -sS
  https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh -o /tmp/install.sh

⏺ API Error: 400 Guardrail raised an exception, Guardrail: download-allowlist,
  Message: Tool 'Bash' denied by rule 'deny-downloads-outside-allowlist'

Blocked injection, where the read itself succeeds because reading a file is not the attack:

❯ Read quarterly_report.html and tell me the revenue growth

  Searched for 1 pattern, read 1 file (ctrl+o to expand)

⏺ API Error: 400 400: {'error': 'Violated guardrail policy', 'bedrock_guardrail_response':
  'Blocked by AWS Bedrock Guardrails: prompt attack detected in model input.', ...
  'type': 'PROMPT_ATTACK', 'confidence': 'HIGH', 'filterStrength': 'HIGH', 'action': 'BLOCKED'}]}],
  'guardrail_name': 'aws-prompt-attack-scanner', 'guardrail_mode': 'pre_call'}

Type

🐛 Bug Fix

Changes

_extract_input_text_and_images read content_item.get("text"), which an Anthropic tool_result block never has: its payload sits under content, as a string or as a list of blocks. Tool results were therefore dropped before any input guardrail ran, and a prompt injection sitting in a file the agent had just read reached the model unscanned. Both forms are now extracted, scanned, and written back in place, so masking guardrails edit the tool result rather than silently no-op

scan_only_tool_results narrows a guardrail to tool output. Agent scaffolding trips PROMPT_ATTACK at every filter strength, so scoping the scan to what actually crosses the trust boundary is what makes a prompt-attack filter usable in front of a coding agent. Narrowing drops coverage, so it takes an explicit true: a yaml string, a placeholder, or an unset value leaves the whole request in scope

ToolPermissionGuardrail's streaming hook assumed ModelResponse chunks. On a streamed /v1/messages passthrough it is handed raw provider SSE byte frames instead, and stream_chunk_builder fails on them, taking the whole route down with a 500. Provider frames are now detected and reassembled through the existing Anthropic passthrough handler before the rules run, then replayed to the client untouched

Two supporting changes fall out of that: _build_complete_streaming_response accepts litellm_logging_obj=None, since stream_chunk_builder already treats it as optional and enforcement must not depend on a logging object existing; and the per-attribute setattr block in guardrail_registry becomes a loop over the scoping parameter names

Tests

tuple-returning helpers and a NamedTuple location type replace the ad-hoc index bookkeeping in the Anthropic handler, so write-back is a match on where each scanned string came from rather than positional guesswork

Coverage lives with the code it exercises: tool_result extraction and masking write-back in the Anthropic handler tests, scan_only_tool_results scoping in the OpenAI handler tests, and denied tool calls carried on real Anthropic SSE byte frames in the tool_permission tests

QA runbook

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

…rough streams

Guardrails could not see the untrusted half of an agent request. Anthropic
tool_result blocks carry their payload under `content`, never `text`, so the
input extractor walked straight past them and a prompt injection sitting in a
file the agent had just read reached the model unscanned. Tool results are now
extracted (string and block forms), scanned, and masked back in place.

Adds `scan_only_tool_results`, which narrows a guardrail to tool output. Agent
scaffolding trips PROMPT_ATTACK at every filter strength, so scoping the scan to
the data crossing the trust boundary is what makes a prompt-attack filter usable
in front of a coding agent. Narrowing drops coverage, so it takes an explicit
true; anything else leaves the whole request in scope.

The tool_permission guardrail also missed streamed /v1/messages traffic, where
the passthrough route hands the hook raw provider SSE frames rather than
ModelResponse chunks. Those frames are now reassembled before the rules run, so
a denied tool call is blocked on both API surfaces.
@mateo-berri
mateo-berri force-pushed the litellm_bedrock_guardrails_demo branch from 4e3575e to ac03912 Compare August 5, 2026 21:25
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds guardrail security hardening for tool-result handling and streamed passthrough responses, together with scoped scanning configuration and regression coverage.

  • Extracts and writes back Anthropic tool-result text in string and block forms.
  • Adds scan_only_tool_results scoping across guardrail configuration and translation handlers.
  • Reassembles raw Anthropic passthrough SSE frames before enforcing tool permissions.
  • Adds tests and a demonstration runbook for the changed behavior.

Confidence Score: 4/5

The implementation appears safe to merge once the non-blocking documentation-placement and typing-convention issues are addressed.

The changed runtime paths have focused regression coverage and no concrete blocking failure remains; the accepted findings concern repository organization and type-annotation consistency.

Files Needing Attention: guardrails_demo/RUNBOOK.md; tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py

Important Files Changed

Filename Overview
litellm/llms/anthropic/chat/guardrail_translation/handler.py Adds location-aware extraction and masking write-back for Anthropic tool-result content while supporting tool-result-only scanning.
litellm/llms/base_llm/guardrail_translation/utils.py Centralizes message-role filtering and explicit scan-only-tool-results scope evaluation.
litellm/llms/openai/chat/guardrail_translation/handler.py Applies tool-result-only filtering to OpenAI message extraction and structured guardrail inputs.
litellm/proxy/guardrails/guardrail_hooks/tool_permission.py Reassembles raw provider SSE frames for streamed tool-policy enforcement and consolidates permission handling.
litellm/proxy/guardrails/guardrail_registry.py Propagates the new scoping parameter to initialized guardrail callbacks.
litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py Allows streaming response reconstruction when no LiteLLM logging object is available.
litellm/types/guardrails.py Adds the optional scan_only_tool_results guardrail configuration field.
tests/test_litellm/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py Adds thorough extraction, masking, and scope tests but introduces two repository typing-convention violations.
tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py Verifies explicit-true scope activation and tool-role-only scanning.
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tool_permission.py Covers denied, allowed, and text-only Anthropic raw SSE streams.
guardrails_demo/RUNBOOK.md Adds extensive customer-facing documentation in violation of the repository's documentation-location rule.

Reviews (1): Last reviewed commit: 4e3575e | Re-trigger Greptile

Comment thread guardrails_demo/RUNBOOK.md Outdated
@@ -0,0 +1,256 @@
# Securing agent tool use with LiteLLM guardrails on AWS Bedrock

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Product documentation in code repository

This customer-facing setup and usage guide is being added to the application repository instead of litellm-docs, splitting documentation across repositories and bypassing the designated documentation workflow.

Rule Used: Prevent documentation from being added - needs to ... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Broad parameter introduces Any

The new logging_obj parameter uses Optional[Any], introducing an avoidable Any into static analysis instead of following the repository convention of using object for broad parameters.

Rule Used: In this repo, prefer object over Optional[Any]... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

def __init__(
self,
guardrail_name: str,
replacement: Optional[str] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Optional annotation breaks local convention

The new replacement parameter uses Optional[str] rather than the repository's PEP 604 str | None convention, adding an inconsistent annotation to this test helper.

Suggested change
replacement: Optional[str] = None,
replacement: str | None = None,

Rule Used: In this repo, prefer str | None (PEP 604 union s... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

skip_tool: bool,
) -> tuple[AllMessageValues, ...]:
"""Narrow the structured messages a guardrail sees, per its skip/scope settings."""
scoped: Final = openai_messages_only_tool(messages) if scan_only_tool_results else tuple(messages)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low: Tool-result guardrail bypass

When scan_only_tool_results and Bedrock's experimental_use_latest_role_message_only are both enabled, this leaves only tool messages, while Bedrock's selector requires a user message and returns skip_scan=True if none exists. An authenticated user can therefore submit malicious tool-result content without invoking the configured scanner. Preserve tool-result eligibility when latest-role filtering is enabled, and add a test covering these options together for both OpenAI and Anthropic requests.

@veria-ai

veria-ai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request updates guardrail handling to scan tool-result content and enforce tool policy on passthrough streams. It also adjusts message-selection behavior across OpenAI- and Anthropic-compatible requests.

One guardrail bypass remains open when tool-result-only scanning is combined with Bedrock’s latest-role filtering. Under that configuration, an authenticated user can submit tool-result content without triggering the configured scanner, so the policy enforcement is not yet complete.

Open issues (1)

Fixed/addressed: 0 · PR risk: 5/10

@mateo-berri

Copy link
Copy Markdown
Contributor Author

Superseded: tool_result scanning shipped in #35999 and scan_only_tool_results in #36014

@mateo-berri mateo-berri closed this Aug 6, 2026
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