fix(redaction): stop redacting the raw call details so post-call scanning still sees tool arguments

Logging.post_call runs redaction before async_post_mcp_tool_call_hook, and that
hook is what post_mcp_call guardrails scan. Redacting mcp_tool_call_metadata on
the shared model_call_details therefore handed the scanner the placeholder
instead of the arguments the caller actually sent, so enabling message redaction
silently disabled tool-argument inspection.

The standard logging payload is still redacted, which is what integrations
export. Enforcement reads the originals again.
This commit is contained in:
Yucheng Zhu 2026-08-11 17:32:38 -07:00
parent 71b09960e9
commit 424ee6913f
2 changed files with 19 additions and 22 deletions

View file

@ -177,22 +177,6 @@ def redacted_mcp_tool_call_metadata(metadata: object, redacted_str: str) -> obje
return {**metadata, "mcp_tool_call_metadata": redacted_call}
def _redact_mcp_tool_call_in_place(model_call_details: dict) -> None:
"""Redact the raw `mcp_tool_call_metadata` the MCP server stamped onto the
call details, so callbacks reading it off `kwargs` see the same redaction
the standard logging payload gets.
"""
mcp_tool_call: Final = model_call_details.get("mcp_tool_call_metadata")
if not isinstance(mcp_tool_call, dict):
return
for key in ("arguments", "result"):
if mcp_tool_call.get(key) is not None:
# mutable-ok: redacting the shared call details in place is the point; a copy would leave
# callbacks that read mcp_tool_call_metadata off kwargs holding the unredacted values
mcp_tool_call[key] = "redacted-by-litellm"
def _redact_standard_logging_object(model_call_details: dict):
"""Redact messages and response inside standard_logging_object if present."""
standard_logging_object: Final = model_call_details.get("standard_logging_object")
@ -278,7 +262,6 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
model_call_details["messages"] = [{"role": "user", "content": "redacted-by-litellm"}]
model_call_details["prompt"] = ""
model_call_details["input"] = ""
_redact_mcp_tool_call_in_place(model_call_details)
_redact_standard_logging_object(model_call_details)
redact_vertex_ai_metadata_from_litellm_params(model_call_details)

View file

@ -778,7 +778,11 @@ class TestRedactMcpToolCallMetadata:
assert redacted_mcp_tool_call_metadata(metadata, "redacted-by-litellm") is metadata
def test_global_redaction_also_strips_the_raw_call_details_copy(self):
def test_global_redaction_leaves_the_raw_call_details_for_post_call_scanning(self):
"""`Logging.post_call` redacts before `async_post_mcp_tool_call_hook` runs, and that
hook is what post_mcp_call guardrails scan. Redacting the shared call details there
would hand the scanner a placeholder instead of the arguments the user actually sent,
so enforcement must keep reading the originals."""
from litellm.litellm_core_utils.redact_messages import perform_redaction
model_call_details = {
@ -788,15 +792,25 @@ class TestRedactMcpToolCallMetadata:
"arguments": {"city": "TOPSECRETCITY"},
"result": {"text": "sensitive"},
},
"standard_logging_object": {"messages": [], "metadata": {}},
"standard_logging_object": {
"messages": [],
"metadata": {
"mcp_tool_call_metadata": {
"name": "get_weather",
"arguments": {"city": "TOPSECRETCITY"},
}
},
},
}
perform_redaction(model_call_details, result=None)
raw = model_call_details["mcp_tool_call_metadata"]
assert raw["arguments"] == "redacted-by-litellm"
assert raw["result"] == "redacted-by-litellm"
assert raw["name"] == "get_weather"
assert raw["arguments"] == {"city": "TOPSECRETCITY"}
assert raw["result"] == {"text": "sensitive"}
exported = model_call_details["standard_logging_object"]["metadata"]["mcp_tool_call_metadata"]
assert exported["arguments"] == "redacted-by-litellm"
def test_payload_without_metadata_does_not_gain_a_metadata_key(self):
"""The callback-logs replay endpoint seeds a payload from an unvalidated