From f6aff5f0896147853eecafb744ad580c87d5bbac Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Sat, 15 Aug 2026 11:50:59 -0700 Subject: [PATCH] fix(panw_prisma_airs): scan tool call arguments that arrive already parsed The tool call slice types arguments as a string, so a client posting parsed JSON failed validation and the whole tool call, name included, read as unscannable and was skipped without ever reaching AIRS. The OpenAI request path forwards client-supplied tool_calls verbatim, so that shape is reachable. Coerce non-string arguments instead of rejecting them, so the content is scanned. --- .../panw_prisma_airs/panw_prisma_airs.py | 16 +++++++++- .../guardrail_hooks/test_panw_prisma_airs.py | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index 51b00af4ed0..7d72fcae4ab 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -15,7 +15,7 @@ from urllib.parse import urlparse import httpx from fastapi import HTTPException -from pydantic import BaseModel, ConfigDict, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError, field_validator from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -65,6 +65,20 @@ class _ToolCallFunctionSlice(BaseModel): name: str | None = None arguments: str | None = None + @field_validator("arguments", mode="before") + @classmethod + def _coerce_arguments(cls, value: object) -> str | None: + """Accept arguments that are already-parsed JSON. + + The OpenAI request path forwards client-supplied ``tool_calls`` verbatim, so a + client can post a dict here. Rejecting it would make the whole tool call read as + unscannable and skip it silently, which is the one outcome a scanner must never + have. + """ + if value is None or isinstance(value, str): + return value + return json.dumps(value) if isinstance(value, (dict, list)) else str(value) + class _ToolCallSlice(BaseModel): model_config = ConfigDict(from_attributes=True, extra="ignore") diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index 0e27c53a9fa..4f1e5caeafd 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -2762,6 +2762,36 @@ class TestPanwAirsToolCallContentScan: assert mock_api.call_args.kwargs["content"] == "list_items" + @pytest.mark.asyncio + async def test_parsed_dict_arguments_are_still_scanned(self, handler): + """A client can post tool call arguments as already-parsed JSON. + + The OpenAI request path forwards client-supplied tool calls verbatim, so this + shape reaches the scanner. It must be scanned, not dropped as unreadable, or the + content is a silent bypass. + """ + + tool_call = { + "id": "call_1", + "type": "function", + "function": {"name": "exfiltrate", "arguments": {"ssn": "123-45-6789"}}, + } + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = {"action": "allow", "category": "benign"} + await handler._scan_tool_calls_for_guardrail( + tool_calls=[tool_call], + is_response=False, + metadata={"user": "test", "model": "gpt-4"}, + call_id="test-call-id", + request_data={"litellm_call_id": "test-call-id"}, + start_time=datetime.now(), + ) + + mock_api.assert_called_once() + assert "123-45-6789" in mock_api.call_args.kwargs["content"] + assert "exfiltrate" in mock_api.call_args.kwargs["content"] + @pytest.mark.asyncio async def test_custom_tool_call_is_skipped(self, handler): """Custom tool calls carry no function payload, so they are skipped instead of crashing."""