fix(guardrails)!: make hide-secrets honor skip_tool_message_in_guardrail (#48689)

* fix(guardrails): make hide-secrets honor skip_tool_message_in_guardrail

With the flag on, per guardrail or in litellm_settings, hide-secrets leaves
role: tool messages and Anthropic tool_result blocks as sent, as the
unified guardrails do. Tool call arguments are still masked.

* test(guardrails): type the skip-tool walker test and assert the whole request
This commit is contained in:
yucheng-berri 2026-10-28 14:49:48 -07:00 • committed by GitHub
parent 66ddbaab86
commit c6ca22ebc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 14 deletions

View file

@ -32,6 +32,7 @@ from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.llms.base_llm.guardrail_translation.utils import effective_skip_tool_message_for_guardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails._content_utils import iter_tool_json_fields, map_tool_json_text, walk_user_text
from litellm.types.guardrails import GuardrailEventHooks
@ -753,7 +754,12 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail):
_masked_entity_count.set({})
# Covers multimodal list content + Responses-API input.
inspected = walk_user_text(data, self.redact_text, self.redact_tool_payload) + self._redact_prompt(data)
inspected = walk_user_text(
data,
self.redact_text,
self.redact_tool_payload,
skip_tool_message=effective_skip_tool_message_for_guardrail(self),
) + self._redact_prompt(data)
if inspected == 0:
# Image-only, empty-text, and unsupported payloads inspected

View file

@ -305,6 +305,11 @@ def iter_message_text(data: Mapping[str, object]) -> Iterator[str]:
yield from _iter_text_parts_in_content(message.get("content"))
def _is_tool_message(message: object) -> bool:
fields: Final = _text_object(message)
return fields is not None and str(fields.get("role") or "").lower() == "tool"
def _call_item(item: object) -> tuple[str, object] | None:
"""Return ``(field, value)`` for a Responses tool-call input item, else None."""
fields: Final = _text_object(item)
@ -316,11 +321,14 @@ def walk_user_text(
data: dict[str, object],
visit: Callable[[str], str],
visit_tool: Callable[[JsonValue], JsonValue] | None = None,
*,
skip_tool_message: bool = False,
) -> int:
"""Rewrite every text fragment in place via ``visit``.
Mutates ``data["messages"]`` and ``data["input"]`` (including tool payloads), ``data["system"]`` and
``data["instructions"]``. Returns the number
``data["instructions"]``. ``skip_tool_message`` leaves ``role: tool`` messages and ``tool_result``
blocks as sent. Returns the number
of fragments visited so callers can short-circuit when nothing was
inspected.
"""
@ -353,6 +361,8 @@ def walk_user_text(
def _rewrite_part(part: Mapping[str, object], native: bool) -> Mapping[str, object]:
nonlocal visited
if skip_tool_message and part.get("type") == "tool_result":
return part
text: Final = _part_text(part)
rewritten: Final = {**part, **({"text": visit(text)} if text is not None else {})}
if text is not None:
@ -384,18 +394,17 @@ def walk_user_text(
return content
messages: Final = data.get("messages")
if isinstance(messages, list):
for message in messages:
if not isinstance(message, dict):
continue
if "content" in message:
message["content"] = _rewrite_content(message["content"])
calls = message.get("tool_calls")
if isinstance(calls, list):
for call in calls:
function = call.get("function") if isinstance(call, dict) else None
if isinstance(function, dict) and "arguments" in function:
function["arguments"] = _rewrite_arguments(function["arguments"])
for message in _object_list(messages) or ():
if (skip_tool_message and _is_tool_message(message)) or not isinstance(message, dict):
continue
if "content" in message:
message["content"] = _rewrite_content(message["content"])
calls = message.get("tool_calls")
if isinstance(calls, list):
for call in calls:
function = call.get("function") if isinstance(call, dict) else None
if isinstance(function, dict) and "arguments" in function:
function["arguments"] = _rewrite_arguments(function["arguments"])
if "system" in data:
data["system"] = _rewrite_content(data["system"]) # rebind-ok: preserve the native hook's in-place contract

View file

@ -413,3 +413,71 @@ def test_resent_tool_history_is_scanned_once(single_worker: Rig, shape: str) ->
second: Final = timed(lambda: single_worker.proxy.request("POST", path, following))
assert first.value.status_code == 200 and second.value.status_code == 200, (first.value.text, second.value.text)
assert_within_factor_of_control(second.seconds, control=first.seconds, factor=0.5)
@pytest.mark.parametrize("placement", ("guardrail", "global"))
def test_skip_tool_message_in_guardrail_forwards_tool_results(tmp_path: Path, placement: str) -> None:
def configure(configuration: dict[str, JsonValue]) -> None:
guardrails: Final = configuration["guardrails"]
settings: Final = configuration["litellm_settings"]
assert isinstance(guardrails, list) and isinstance(guardrails[0], dict) and isinstance(settings, dict)
params: Final = guardrails[0]["litellm_params"]
assert isinstance(params, dict)
(params if placement == "guardrail" else settings)["skip_tool_message_in_guardrail"] = True
with tool_rig(tmp_path, configure=configure) as rig:
rig.wire.drain()
replies: Final = (
rig.proxy.request(
"POST",
"/v1/messages",
{
"model": "claude",
"max_tokens": 32,
"cache": {"no-cache": True},
"messages": [
{"role": "user", "content": TEXT},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {"env": TEXT}}
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "tool_1", "content": TEXT}],
},
],
},
),
rig.proxy.request(
"POST",
"/v1/chat/completions",
{
"model": "gpt",
"cache": {"no-cache": True},
"messages": [
{"role": "user", "content": TEXT},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "read_file", "arguments": json.dumps({"env": TEXT})},
}
],
},
{"role": "tool", "tool_call_id": "call_1", "content": TEXT},
],
},
),
)
assert all(reply.status_code == 200 for reply in replies), [reply.text for reply in replies]
sent: Final = rig.wire.drain()
assert len(sent) == 2
for request in sent:
messages = json.loads(request.body)["messages"]
assert SECRET not in json.dumps(messages[:2])
assert SECRET in json.dumps(messages[2])

View file

@ -2,6 +2,7 @@
import json
from functools import reduce
from typing import Final
import pytest
from pydantic import JsonValue
@ -924,6 +925,34 @@ def test_native_writer_visits_each_tool_and_system_slot(data):
assert "payload" not in json.dumps(data)
def test_skip_tool_message_leaves_tool_results_and_masks_the_rest() -> None:
data: Final[dict[str, object]] = {
"messages": [
{"role": "user", "content": "secret"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "tool", "name": "read", "input": {"env": "secret"}}],
"tool_calls": [{"id": "call", "function": {"name": "read", "arguments": '{"env": "secret"}'}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool", "content": "secret"}]},
{"role": "tool", "tool_call_id": "call", "content": "secret"},
]
}
assert walk_user_text(data, lambda text: text.replace("secret", "[MASKED]"), skip_tool_message=True) == 3
assert data == {
"messages": [
{"role": "user", "content": "[MASKED]"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "tool", "name": "read", "input": {"env": "[MASKED]"}}],
"tool_calls": [{"id": "call", "function": {"name": "read", "arguments": '{"env": "[MASKED]"}'}}],
},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool", "content": "secret"}]},
{"role": "tool", "tool_call_id": "call", "content": "secret"},
]
}
@pytest.mark.parametrize(
"part",
(