diff --git a/litellm/proxy/guardrails/guardrail_hooks/presidio.py b/litellm/proxy/guardrails/guardrail_hooks/presidio.py index 95876a55eab..10b46b35778 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/presidio.py +++ b/litellm/proxy/guardrails/guardrail_hooks/presidio.py @@ -76,6 +76,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): presidio_anonymizer_api_base: Optional[str] = None, output_parse_pii: Optional[bool] = False, apply_to_output: bool = False, + mask_pii_fail_closed: bool = False, presidio_ad_hoc_recognizers: Optional[str] = None, logging_only: Optional[bool] = None, pii_entities_config: Optional[Dict[Union[PiiEntityType, str], PiiAction]] = None, @@ -93,6 +94,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): self.mock_redacted_text = mock_redacted_text self.output_parse_pii = output_parse_pii or False self.apply_to_output = apply_to_output + self.mask_pii_fail_closed = mask_pii_fail_closed # When output_parse_pii or apply_to_output is enabled, the guardrail must # also run on post_call to unmask/mask the response. Expand the event_hook @@ -298,7 +300,12 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail): def _fail_on_invalid_response( reason: str, ) -> List[PresidioAnalyzeResponseItem]: - should_fail_closed = bool(self.pii_entities_config) or self.output_parse_pii or self.apply_to_output + should_fail_closed = ( + bool(self.pii_entities_config) + or self.output_parse_pii + or self.apply_to_output + or self.mask_pii_fail_closed + ) if should_fail_closed: raise GuardrailRaisedException( guardrail_name=self.guardrail_name, diff --git a/litellm/proxy/guardrails/guardrail_initializers.py b/litellm/proxy/guardrails/guardrail_initializers.py index e8abf66a6f7..12febc4cbee 100644 --- a/litellm/proxy/guardrails/guardrail_initializers.py +++ b/litellm/proxy/guardrails/guardrail_initializers.py @@ -97,7 +97,10 @@ def initialize_presidio(litellm_params: LitellmParams, guardrail: Guardrail): apply_to_output=False, ) params.update(overrides) - callback = _OPTIONAL_PresidioPIIMasking(**params) + callback = _OPTIONAL_PresidioPIIMasking( + **params, + mask_pii_fail_closed=bool(litellm_params.mask_pii_fail_closed), + ) litellm.logging_callback_manager.add_litellm_callback(callback) return callback diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 889e029b902..3d9a2233e39 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -345,6 +345,12 @@ class PresidioPresidioConfigModelUserInterface(BaseModel): # extra param to let the ui know this is a boolean json_schema_extra={"ui_type": GuardrailParamUITypes.BOOL}, ) + mask_pii_fail_closed: Optional[bool] = Field( + default=None, + description="When True, a mask-only Presidio guardrail blocks the request (fail closed) if the analyzer errors, instead of forwarding unmasked text", + # extra param to let the ui know this is a boolean + json_schema_extra={"ui_type": GuardrailParamUITypes.BOOL}, + ) presidio_language: Optional[str] = Field( default="en", description="Language code for Presidio PII analysis (e.g., 'en', 'de', 'es', 'fr')", diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py index 253d989f203..4838222b979 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_presidio.py @@ -1079,6 +1079,55 @@ async def test_analyze_text_invalid_response_raises_when_mask_configured(): assert "PII protection is configured" in str(exc_info.value) +@pytest.mark.asyncio +async def test_analyze_text_mask_only_fails_open_by_default(): + """ + Default mask-only config (no pii_entities_config / output / apply_to_output) + still fails open on analyzer error, preserving prior behaviour (issue #30728). + """ + presidio = _OPTIONAL_PresidioPIIMasking( + presidio_analyzer_api_base="http://mock-presidio:5002/", + presidio_anonymizer_api_base="http://mock-presidio:5001/", + output_parse_pii=False, + ) + assert presidio.mask_pii_fail_closed is False + + with patch.object( + presidio, + "_get_session_iterator", + _make_mock_session_iterator("Internal Server Error", status=500), + ): + result = await presidio.analyze_text( + text="some text", presidio_config=None, request_data={} + ) + assert result == [] + + +@pytest.mark.asyncio +async def test_analyze_text_raises_when_mask_pii_fail_closed_set(): + """ + With mask_pii_fail_closed=True, a mask-only guardrail blocks (fail closed) + on analyzer error instead of forwarding unmasked text (issue #30728). + """ + presidio = _OPTIONAL_PresidioPIIMasking( + presidio_analyzer_api_base="http://mock-presidio:5002/", + presidio_anonymizer_api_base="http://mock-presidio:5001/", + output_parse_pii=False, + mask_pii_fail_closed=True, + ) + + with patch.object( + presidio, + "_get_session_iterator", + _make_mock_session_iterator("Internal Server Error", status=500), + ): + with pytest.raises(GuardrailRaisedException) as exc_info: + await presidio.analyze_text( + text="some text", presidio_config=None, request_data={} + ) + assert "PII protection is configured" in str(exc_info.value) + + @pytest.mark.asyncio async def test_analyze_text_list_with_non_dict_items(): """