From 1171205d18c14ec0b6897b41ead490370a57bbd1 Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 13 Oct 2026 02:00:17 +0000 Subject: [PATCH 1/4] feat(guardrails): opt-in response_masked_data on PANW Prisma AIRS block Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../panw_prisma_airs/__init__.py | 14 +- .../panw_prisma_airs/panw_prisma_airs.py | 8 +- .../guardrails/guardrail_initializers.py | 1 + .../guardrail_hooks/panw_prisma_airs.py | 5 + .../guardrail_hooks/test_panw_prisma_airs.py | 124 ++++++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/__init__.py index 2401012de32..9e7199f88c5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/__init__.py @@ -17,14 +17,12 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" if not guardrail_name: raise ValueError("PANW Prisma AIRS: guardrail_name is required") - _panw_callback: Final = PanwPrismaAirsHandler( - **{ - **litellm_params.model_dump(exclude_unset=True), - "guardrail_name": guardrail_name, - "event_hook": litellm_params.mode, - "default_on": litellm_params.default_on or False, - } - ) + litellm_params_dump: Final = litellm_params.model_dump(exclude_unset=True) + litellm_params_dump["guardrail_name"] = guardrail_name + litellm_params_dump["event_hook"] = litellm_params.mode + litellm_params_dump["default_on"] = litellm_params.default_on or False + + _panw_callback: Final = PanwPrismaAirsHandler(**litellm_params_dump) litellm.logging_callback_manager.add_litellm_callback(_panw_callback) return _panw_callback 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 0607996d13d..654710a9989 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 @@ -159,6 +159,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): #: back would hand the caller exactly the text the operator declined to deliver. #: ``prompt_masked_data`` is deliberately NOT withheld: it is the caller's own input, #: and it is one of the fields the ticket asks for. + #: The ``include_masked_response_on_block`` config opt-in lifts the withholding. _CLIENT_HIDDEN_SCAN_FIELDS: Final = frozenset({"response_masked_data"}) def __init__( @@ -171,6 +172,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): mask_on_block: bool = False, mask_request_content: bool = False, mask_response_content: bool = False, + include_masked_response_on_block: bool = False, app_name: str | None = None, fallback_on_error: Literal["block", "allow"] = "block", timeout: float = 10.0, @@ -225,6 +227,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): self.http_client = http_client self.fallback_on_error = fallback_on_error + self.include_masked_response_on_block = include_masked_response_on_block # Coerce defensively. The dashboard UI persists this field as a JSON # string, and Pydantic extras (the path that splats model_dump into # this handler) preserve whatever type the user supplied. A string @@ -743,12 +746,15 @@ class PanwPrismaAirsHandler(CustomGuardrail): }, ) + hidden_fields: Final = ( + frozenset[str]() if self.include_masked_response_on_block else self._CLIENT_HIDDEN_SCAN_FIELDS + ) return { "error": { **{ key: value for key, value in scan_result.items() - if not key.startswith("_") and key not in self._CLIENT_HIDDEN_SCAN_FIELDS + if not key.startswith("_") and key not in hidden_fields }, "message": error_msg, "type": "guardrail_violation", diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index 0b38f2d9488..288d86de137 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -282,6 +282,7 @@ def initialize_panw_prisma_airs(litellm_params, guardrail): mask_on_block=getattr(litellm_params, "mask_on_block", False), mask_request_content=getattr(litellm_params, "mask_request_content", False), mask_response_content=getattr(litellm_params, "mask_response_content", False), + include_masked_response_on_block=getattr(litellm_params, "include_masked_response_on_block", False), # pyright: ignore[reportUnknownArgumentType] # litellm_params is untyped in this function; pydantic extras require getattr app_name=getattr(litellm_params, "app_name", None), fallback_on_error=getattr(litellm_params, "fallback_on_error", "block"), # `timeout` is now declared on BaseLitellmParams (Optional[float] = None), diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/panw_prisma_airs.py b/litellm/types/proxy/guardrails/guardrail_hooks/panw_prisma_airs.py index 14d541b2bf0..893ff95abab 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/panw_prisma_airs.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/panw_prisma_airs.py @@ -40,6 +40,11 @@ class PanwPrismaAirsGuardrailConfigModel(GuardrailConfigModel): description="Apply masking to responses that would be blocked. When True, masked content is returned to the user instead of blocking the response.", ) + include_masked_response_on_block: bool = Field( + default=False, + description="When a response is blocked, include AIRS response_masked_data (masked text and pattern_detections) in the 400 error body. Has no effect when mask_response_content is on, because that returns the masked text with a 200 instead.", + ) + fallback_on_error: Literal["block", "allow"] = Field( default="block", description="Action when PANW API is unavailable (timeout, rate limit, network error): 'block' (default, maximum security) rejects requests; 'allow' (high availability) proceeds without scanning. Authentication and configuration errors always block.", diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index a5c543528c2..9c4ad20fd64 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -6146,6 +6146,130 @@ class TestPanwAirsErrorDetailWithheldFields: assert detail["error"]["scan_id"] == "scan-2" +class TestPanwAirsIncludeMaskedResponseOnBlock: + """LIT-9336: opt-in passthrough of AIRS ``response_masked_data`` in the 400 body. + + With ``include_masked_response_on_block=True`` the withheld + ``_CLIENT_HIDDEN_SCAN_FIELDS`` lift, so the 400 detail carries the masked + text and ``pattern_detections`` exactly as AIRS returned them. + """ + + _CUSTOMER_BLOCK_RESPONSE = { + "action": "block", + "category": "malicious", + "error": False, + "errors": [], + "profile_id": "a6ecc0c6-9aee-41bf-b967-22c690a8b989", + "profile_name": "DLP_PII_NexusOS", + "prompt_detected": {}, + "report_id": "Rec62eb82-78a5-43b6-b4fe-9e9dab03ff76", + "response_detected": { + "dlp": True, + "toxic_content": False, + "ungrounded": False, + }, + "scan_id": "ec62eb82-78a5-43b6-b4fe-9e9dab03ff76", + "session_id": "2718c56e-a46d-47b1-afed-573c16c67d89", + "source": "AI-Runtime-API", + "timeout": False, + "tool_detected": {}, + "tr_id": "2718c56e-a46d-47b1-afed-573c16c67d89", + "transaction_id": "pan_8e754890-5816-46dc-99ba-c881b7603a08", + "response_masked_data": { + "data": "Sample (fictional) values:\n\n- Email ID: `XXXXXXXXXXXXXXXXXXXXX`\n- SSN: `XXXXXXXXXXX`", + "pattern_detections": [ + {"locations": [[72, 74], [76, 82]], "pattern": "License Plate Number - USA"}, + {"locations": [[72, 74], [79, 82]], "pattern": "Credit Card CVV"}, + {"locations": [[72, 82]], "pattern": "Driver License - US"}, + {"locations": [[41, 61]], "pattern": "Internet - addr_email"}, + ], + }, + } + + @pytest.mark.asyncio + async def test_block_includes_response_masked_data_when_opted_in(self): + handler = make_handler(include_masked_response_on_block=True) + inputs: GenericGuardrailAPIInputs = {"texts": ["Sensitive response data"]} + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = copy.deepcopy(self._CUSTOMER_BLOCK_RESPONSE) + + with pytest.raises(HTTPException) as exc_info: + await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) + + assert exc_info.value.status_code == 400 + error = exc_info.value.detail["error"] + + assert error["response_masked_data"] == self._CUSTOMER_BLOCK_RESPONSE["response_masked_data"] + + for field in self._CUSTOMER_BLOCK_RESPONSE: + if field == "category": + continue + assert error[field] == self._CUSTOMER_BLOCK_RESPONSE[field], f"{field} missing or altered" + + assert error["code"] == "panw_prisma_airs_response_blocked" + assert error["type"] == "guardrail_violation" + assert error["guardrail"] == "test_panw_airs" + assert error["category"] == "malicious" + + @pytest.mark.asyncio + async def test_block_without_response_masked_data_stays_400(self): + """The opt-in passes through what AIRS sent; it does not invent the key.""" + handler = make_handler(include_masked_response_on_block=True) + inputs: GenericGuardrailAPIInputs = {"texts": ["Sensitive response data"]} + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = { + "action": "block", + "category": "malicious", + "scan_id": "scan-no-mask", + } + + with pytest.raises(HTTPException) as exc_info: + await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) + + assert exc_info.value.status_code == 400 + error = exc_info.value.detail["error"] + assert "response_masked_data" not in error + assert error["scan_id"] == "scan-no-mask" + assert error["code"] == "panw_prisma_airs_response_blocked" + + @pytest.mark.asyncio + async def test_mask_response_content_still_masks_when_flag_on(self): + """mask_response_content wins over the flag: masked text goes out as a 200.""" + handler = make_handler( + include_masked_response_on_block=True, + mask_response_content=True, + ) + inputs: GenericGuardrailAPIInputs = {"texts": ["Sensitive response data"]} + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = { + "action": "block", + "category": "dlp", + "response_masked_data": {"data": "XXXXXXXXX response data"}, + } + + result = await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) + + assert result["texts"] == ["XXXXXXXXX response data"] + + class TestPanwAirsToolCallBlockMaskedDataRouting: """A tool-call block must withhold model output and keep caller input. From e4f677daa95aa9e6390a2a2f0da78d5c850ed277 Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 13 Oct 2026 13:11:18 +0000 Subject: [PATCH 2/4] test(guardrails): integration cells for PANW include_masked_response_on_block Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_panw_airs_block_masked_response.py | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 tests/integration/observability/test_panw_airs_block_masked_response.py diff --git a/tests/integration/observability/test_panw_airs_block_masked_response.py b/tests/integration/observability/test_panw_airs_block_masked_response.py new file mode 100644 index 00000000000..23dee1a1187 --- /dev/null +++ b/tests/integration/observability/test_panw_airs_block_masked_response.py @@ -0,0 +1,314 @@ +"""LIT-9336: ``include_masked_response_on_block`` decides whether the 400 a PANW +post_call block raises also carries AIRS ``response_masked_data`` (masked text +plus ``pattern_detections``) back to the caller. + +The double returns allow on prompt scans and a customer-shaped block body on +response scans, so every denial here is a post_call block. +""" + +import json +import uuid +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +import pytest +import yaml +from integration._support.client import Gateway, object_value +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue + +_MODEL_TEXT: Final = "sensitive generation" + + +def _masked_data() -> dict[str, JsonValue]: + return { + "data": "Sample (fictional) values:\n\n- Email ID: `XXXXXXXXXXXXXXXXXXXXX`\n- SSN: `XXXXXXXXXXX`", + "pattern_detections": [ + {"locations": [[72, 74], [76, 82]], "pattern": "License Plate Number - USA"}, + {"locations": [[72, 74], [79, 82]], "pattern": "Credit Card CVV"}, + {"locations": [[72, 82]], "pattern": "Driver License - US"}, + {"locations": [[41, 61]], "pattern": "Internet - addr_email"}, + ], + } + + +def _block_scan(with_masked_data: bool = True) -> dict[str, JsonValue]: + scan_id: Final = uuid.uuid4().hex + session_id: Final = uuid.uuid4().hex + return { + "action": "block", + "category": "malicious", + "error": False, + "errors": [], + "profile_id": "a6ecc0c6-9aee-41bf-b967-22c690a8b989", + "profile_name": "DLP_PII_NexusOS", + "prompt_detected": {}, + "report_id": "R" + scan_id, + "response_detected": {"dlp": True, "toxic_content": False, "ungrounded": False}, + "scan_id": scan_id, + "session_id": session_id, + "source": "AI-Runtime-API", + "timeout": False, + "tool_detected": {}, + "tr_id": session_id, + "transaction_id": "pan_" + uuid.uuid4().hex, + **({"response_masked_data": _masked_data()} if with_masked_data else {}), + } + + +def _scanner(request: Request) -> Reply: + assert request.target == "/v1/scan/sync/request" + contents: Final = json.loads(request.body)["contents"][0] + if "response" in contents: + return Reply(body=json.dumps(_block_scan()).encode()) + return Reply( + body=json.dumps({"action": "allow", "category": "benign", "response_detected": {}}).encode() + ) + + +def _scanner_without_masked_data(request: Request) -> Reply: + assert request.target == "/v1/scan/sync/request" + contents: Final = json.loads(request.body)["contents"][0] + if "response" in contents: + return Reply(body=json.dumps(_block_scan(with_masked_data=False)).encode()) + return Reply( + body=json.dumps({"action": "allow", "category": "benign", "response_detected": {}}).encode() + ) + + +def _provider(request: Request) -> Reply: + if request.method == "GET" and request.target.endswith("/models"): + return Reply(body=json.dumps({"object": "list", "data": []}).encode()) + if request.target == "/v1/chat/completions": + return Reply( + body=json.dumps( + { + "id": "chatcmpl_synthetic", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4.1-mini", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": _MODEL_TEXT}, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, + } + ).encode() + ) + if request.target == "/v1/responses": + return Reply( + body=json.dumps( + { + "id": "resp_" + uuid.uuid4().hex, + "object": "response", + "created_at": 1700000000, + "status": "completed", + "model": "gpt-4.1-mini", + "output": [ + { + "type": "message", + "id": "msg_synthetic", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": _MODEL_TEXT, "annotations": []}], + } + ], + "usage": {"input_tokens": 11, "output_tokens": 4, "total_tokens": 15}, + } + ).encode() + ) + if request.target == "/v1/messages": + return Reply( + body=json.dumps( + { + "id": "msg_" + uuid.uuid4().hex, + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": _MODEL_TEXT}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 11, "output_tokens": 4}, + } + ).encode() + ) + raise AssertionError(f"unexpected provider target {request.target}") + + +def _config(tmp_path: Path, identity: str, policy_url: str, **flags: bool) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["guardrails"] = [ + { + "guardrail_name": identity, + "litellm_params": { + "guardrail": "panw_prisma_airs", + "mode": "post_call", + "default_on": True, + "api_base": policy_url, + "api_key": "synthetic-panw-key", + "profile_name": "synthetic-profile", + **flags, + }, + } + ] + path: Final = tmp_path / "panw_block.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + +def _block_detail(node: JsonValue) -> Mapping[str, JsonValue] | None: + if isinstance(node, dict): + if node.get("code") == "panw_prisma_airs_response_blocked": + return node + for value in node.values(): + found: Final = _block_detail(value) + if found is not None: + return found + if isinstance(node, list): + for value in node: + found: Final = _block_detail(value) + if found is not None: + return found + return None + + +def _response_scans(requests: tuple[Request, ...]) -> list[dict[str, JsonValue]]: + return [ + contents + for request in requests + for contents in json.loads(request.body)["contents"] + if "response" in contents + ] + + +def _request_body(model: str, target: str) -> dict[str, JsonValue]: + if target == "/v1/responses": + return {"model": model, "input": "say hi"} + if target == "/v1/messages": + return {"model": model, "max_tokens": 16, "messages": [{"role": "user", "content": "say hi"}]} + return {"model": model, "messages": [{"role": "user", "content": "say hi"}]} + + +@pytest.mark.parametrize("target", ["/v1/chat/completions", "/v1/responses", "/v1/messages"]) +def test_block_includes_response_masked_data_when_opted_in(gateway: Gateway, tmp_path: Path, target: str) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + with wire_server(_scanner) as policy, wire_server(_provider) as upstream: + config: Final = _config(tmp_path, identity, policy.url, include_masked_response_on_block=True) + with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = candidate.request("POST", target, _request_body(model, target)) + + assert response.status_code == 400, response.text + detail: Final = _block_detail(object_value(response.json())) + assert detail is not None, f"blocked detail missing from {response.text}" + assert detail["action"] == "block" + assert detail["category"] == "malicious" + assert detail["response_masked_data"] == _masked_data() + assert _response_scans(policy.drain()) != [], "the AIRS double never saw a response scan" + + +def test_block_withholds_response_masked_data_by_default(gateway: Gateway, tmp_path: Path) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + with wire_server(_scanner) as policy, wire_server(_provider) as upstream: + config: Final = _config(tmp_path, identity, policy.url) + with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = candidate.request( + "POST", "/v1/chat/completions", _request_body(model, "/v1/chat/completions") + ) + + assert response.status_code == 400, response.text + detail: Final = _block_detail(object_value(response.json())) + assert detail is not None, f"blocked detail missing from {response.text}" + assert "response_masked_data" not in detail + assert detail["action"] == "block" + assert _response_scans(policy.drain()) != [] + + +def test_block_without_masked_data_invents_none(gateway: Gateway, tmp_path: Path) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + with wire_server(_scanner_without_masked_data) as policy, wire_server(_provider) as upstream: + config: Final = _config(tmp_path, identity, policy.url, include_masked_response_on_block=True) + with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = candidate.request( + "POST", "/v1/chat/completions", _request_body(model, "/v1/chat/completions") + ) + + assert response.status_code == 400, response.text + detail: Final = _block_detail(object_value(response.json())) + assert detail is not None, f"blocked detail missing from {response.text}" + assert "response_masked_data" not in detail + assert _MODEL_TEXT not in response.text + + +def test_mask_response_content_still_masks_when_flag_on(gateway: Gateway, tmp_path: Path) -> None: + identity: Final = "guardrail" + uuid.uuid4().hex + with wire_server(_scanner) as policy, wire_server(_provider) as upstream: + config: Final = _config( + tmp_path, + identity, + policy.url, + include_masked_response_on_block=True, + mask_response_content=True, + ) + with owned_proxy(gateway, tmp_path, {}, config=config) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = candidate.request( + "POST", "/v1/chat/completions", _request_body(model, "/v1/chat/completions") + ) + + assert response.status_code == 200, response.text + content: Final = response.json()["choices"][0]["message"]["content"] + assert content == _masked_data()["data"] + + +def test_db_created_guardrail_includes_response_masked_data_on_block(gateway: Gateway, tmp_path: Path) -> None: + name: Final = f"panw-block-details-{uuid.uuid4().hex[:8]}" + with wire_server(_scanner) as policy, wire_server(_provider) as upstream: + gateway.post( + "/guardrails", + { + "guardrail": { + "guardrail_name": name, + "litellm_params": { + "guardrail": "panw_prisma_airs", + "mode": "post_call", + "api_base": policy.url, + "api_key": "synthetic-panw-key", + "profile_name": "synthetic-profile", + "include_masked_response_on_block": True, + }, + } + }, + ) + with gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {**_request_body(model, "/v1/chat/completions"), "guardrails": [name]}, + ) + + assert response.status_code == 400, response.text + detail: Final = _block_detail(object_value(response.json())) + assert detail is not None, f"blocked detail missing from {response.text}" + assert detail["action"] == "block" + assert detail["category"] == "malicious" + assert detail["response_masked_data"] == _masked_data() + assert _response_scans(policy.drain()) != [], "the AIRS double never saw a response scan" From 6d8768fb81c0c8ec49e509113e19669e14d2b496 Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 13 Oct 2026 19:53:44 +0000 Subject: [PATCH 3/4] fix(guardrails): reject non-boolean include_masked_response_on_block Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../panw_prisma_airs/panw_prisma_airs.py | 22 ++++++++- .../test_panw_airs_block_masked_response.py | 37 ++++++++++++++ .../guardrail_hooks/test_panw_prisma_airs.py | 49 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) 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 654710a9989..aa5482b3997 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 @@ -133,6 +133,20 @@ if TYPE_CHECKING: from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel +def _parse_bool_setting(name: str, value: object) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, str): + from litellm.secret_managers.main import str_to_bool + + parsed: Final = str_to_bool(value) + if parsed is not None: + return parsed + raise ValueError(f"PANW Prisma AIRS: {name} must be true or false, got {value!r}") + + class PanwPrismaAirsHandler(CustomGuardrail): """ LiteLLM Built-in Guardrail for Palo Alto Networks Prisma AI Runtime Security (AIRS). @@ -172,7 +186,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): mask_on_block: bool = False, mask_request_content: bool = False, mask_response_content: bool = False, - include_masked_response_on_block: bool = False, + include_masked_response_on_block: bool | str | None = False, app_name: str | None = None, fallback_on_error: Literal["block", "allow"] = "block", timeout: float = 10.0, @@ -227,7 +241,11 @@ class PanwPrismaAirsHandler(CustomGuardrail): self.http_client = http_client self.fallback_on_error = fallback_on_error - self.include_masked_response_on_block = include_masked_response_on_block + # The dashboard persists some fields as strings and Pydantic extras pass + # them through unvalidated, so a bare "false" must not be treated as truthy. + self.include_masked_response_on_block: Final[bool] = _parse_bool_setting( + "include_masked_response_on_block", include_masked_response_on_block + ) # Coerce defensively. The dashboard UI persists this field as a JSON # string, and Pydantic extras (the path that splats model_dump into # this handler) preserve whatever type the user supplied. A string diff --git a/tests/integration/observability/test_panw_airs_block_masked_response.py b/tests/integration/observability/test_panw_airs_block_masked_response.py index 23dee1a1187..b111507f520 100644 --- a/tests/integration/observability/test_panw_airs_block_masked_response.py +++ b/tests/integration/observability/test_panw_airs_block_masked_response.py @@ -276,6 +276,43 @@ def test_mask_response_content_still_masks_when_flag_on(gateway: Gateway, tmp_pa assert content == _masked_data()["data"] +def test_db_created_guardrail_string_false_withholds_response_masked_data_on_block(gateway: Gateway) -> None: + """The dashboard persists some litellm_params as strings; "false" must not turn the flag on.""" + name: Final = f"panw-string-false-{uuid.uuid4().hex[:8]}" + with wire_server(_scanner) as policy, wire_server(_provider) as upstream: + gateway.post( + "/guardrails", + { + "guardrail": { + "guardrail_name": name, + "litellm_params": { + "guardrail": "panw_prisma_airs", + "mode": "post_call", + "api_base": policy.url, + "api_key": "synthetic-panw-key", + "profile_name": "synthetic-profile", + "include_masked_response_on_block": "false", + }, + } + }, + ) + with gateway.scenario() as scenario: + model: Final = scenario.model( + model="openai/gpt-4.1-mini", api_base=upstream.url + "/v1", api_key="synthetic-key" + ) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {**_request_body(model, "/v1/chat/completions"), "guardrails": [name]}, + ) + + assert response.status_code == 400, response.text + detail: Final = _block_detail(object_value(response.json())) + assert detail is not None, f"blocked detail missing from {response.text}" + assert "response_masked_data" not in detail + assert _response_scans(policy.drain()) != [], "the AIRS double never saw a response scan" + + def test_db_created_guardrail_includes_response_masked_data_on_block(gateway: Gateway, tmp_path: Path) -> None: name: Final = f"panw-block-details-{uuid.uuid4().hex[:8]}" with wire_server(_scanner) as policy, wire_server(_provider) as upstream: diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py index 9c4ad20fd64..0737a56d66e 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_panw_prisma_airs.py @@ -6269,6 +6269,55 @@ class TestPanwAirsIncludeMaskedResponseOnBlock: assert result["texts"] == ["XXXXXXXXX response data"] + @pytest.mark.asyncio + @pytest.mark.parametrize("raw", ["false", "False", " false "]) + async def test_string_false_withholds_response_masked_data(self, raw): + """A persisted "false" string must not be truthy on the withhold check.""" + handler = make_handler(include_masked_response_on_block=raw) + inputs: GenericGuardrailAPIInputs = {"texts": ["Sensitive response data"]} + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = copy.deepcopy(self._CUSTOMER_BLOCK_RESPONSE) + + with pytest.raises(HTTPException) as exc_info: + await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) + + assert exc_info.value.status_code == 400 + error = exc_info.value.detail["error"] + assert "response_masked_data" not in error + assert error["code"] == "panw_prisma_airs_response_blocked" + + @pytest.mark.asyncio + @pytest.mark.parametrize("raw", ["true", "TRUE"]) + async def test_string_true_includes_response_masked_data(self, raw): + handler = make_handler(include_masked_response_on_block=raw) + inputs: GenericGuardrailAPIInputs = {"texts": ["Sensitive response data"]} + request_data = {"litellm_call_id": "test-call-id", "model": "gpt-4"} + + with patch.object(handler, "_call_panw_api", new_callable=AsyncMock) as mock_api: + mock_api.return_value = copy.deepcopy(self._CUSTOMER_BLOCK_RESPONSE) + + with pytest.raises(HTTPException) as exc_info: + await handler.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="response", + ) + + assert exc_info.value.status_code == 400 + error = exc_info.value.detail["error"] + assert error["response_masked_data"] == self._CUSTOMER_BLOCK_RESPONSE["response_masked_data"] + + @pytest.mark.parametrize("raw", ["yes", 1, ""]) + def test_non_boolean_value_fails_closed(self, raw): + with pytest.raises(ValueError, match="include_masked_response_on_block"): + make_handler(include_masked_response_on_block=raw) + class TestPanwAirsToolCallBlockMaskedDataRouting: """A tool-call block must withhold model output and keep caller input. From 8200451e3c1b66a78ebc592e9f00cf86505c4817 Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 13 Oct 2026 20:51:37 +0000 Subject: [PATCH 4/4] docs(guardrails): update PANW tool-call comment for include_masked_response_on_block Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 aa5482b3997..330482bbd38 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 @@ -1567,9 +1567,10 @@ class PanwPrismaAirsHandler(CustomGuardrail): else: # Tool calls now go out as ordinary prompt/response text, so a # response-side scan reports the model's arguments under - # response_masked_data, which _CLIENT_HIDDEN_SCAN_FIELDS already - # withholds. prompt_masked_data is the caller's own input again and - # must keep reaching them -- it is one of the fields LIT-5638 asks for. + # response_masked_data, which _CLIENT_HIDDEN_SCAN_FIELDS withholds + # unless include_masked_response_on_block is on. prompt_masked_data is + # the caller's own input again and must keep reaching them -- it is one + # of the fields LIT-5638 asks for. error_detail = self._build_error_detail(scan_result, is_response=is_response) raise HTTPException(status_code=400, detail=error_detail)