From 424ee6913f84af11e82ee55453e83dbabbd332f3 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Tue, 11 Aug 2026 17:32:38 -0700 Subject: [PATCH] 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. --- litellm/litellm_core_utils/redact_messages.py | 17 ------------- .../test_redact_messages.py | 24 +++++++++++++++---- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 9d2dd85164f..29cfb3134c2 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -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) diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index e5f8c0ab29b..7a38edf527b 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -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