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.
This commit is contained in:
Yucheng Zhu 2026-08-15 11:50:59 -07:00
parent a477c3e841
commit f6aff5f089
2 changed files with 45 additions and 1 deletions

View file

@ -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")

View file

@ -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."""