mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
feat(guardrails): add block_entities to the PrivAiTe guardrail
A request containing any PII type listed in block_entities is now rejected with an HTTP 400 in the pre-call hook, before anything is forwarded to the model, instead of being pseudonymized. Empty by default, so existing behavior is unchanged. Accepts a list or a comma-separated string, and the error names the blocked type(s) only, never the value. Because privaite's PIIConfig uses extra="allow", an older privaite would silently swallow block_entities and forward the PII anyway; the guardrail fails closed when block rules are set against a privaite that lacks the field. Wires the option through the ConfigModel and initializer, with tests (the fake engine now raises PIIBlockedError for a blocked type).
This commit is contained in:
parent
483d57ee75
commit
ed5dc6fd98
4 changed files with 151 additions and 5 deletions
|
|
@ -18,6 +18,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
|
|||
preset=getattr(litellm_params, "preset", None),
|
||||
languages=getattr(litellm_params, "languages", None),
|
||||
deanonymize=getattr(litellm_params, "deanonymize", True),
|
||||
block_entities=getattr(litellm_params, "block_entities", None),
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(_privaite_callback)
|
||||
return _privaite_callback
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ streamed function_call. (Responses API streaming restore is not yet implemented.
|
|||
On the failure path the map is dropped from metadata as well, so it cannot reach a
|
||||
failure spend-log.
|
||||
|
||||
If `block_entities` is configured, a request containing any of those PII types is
|
||||
rejected with an HTTP 400 in the pre-call hook, before anything is forwarded to the
|
||||
model. The error names the offending type(s) only, never the underlying value.
|
||||
|
||||
It reuses PrivAiTe's engine, so there is no detection or masking logic here. The
|
||||
'privaite' package is imported lazily so this module stays importable (for the
|
||||
guardrail registry) even when PrivAiTe is not installed.
|
||||
|
|
@ -60,6 +64,9 @@ class PrivaiteGuardrail(CustomGuardrail):
|
|||
self.deanonymize = (
|
||||
deanon if isinstance(deanon, bool) else str(deanon).strip().lower() not in ("false", "0", "no", "")
|
||||
)
|
||||
# PII TYPES to reject outright (empty = mask everything, the default).
|
||||
# Accepts a list or a comma-separated string.
|
||||
self.block_entities = self._parse_block_entities(kwargs.pop("block_entities", None))
|
||||
if "supported_event_hooks" not in kwargs:
|
||||
kwargs["supported_event_hooks"] = [
|
||||
GuardrailEventHooks.pre_call,
|
||||
|
|
@ -92,8 +99,16 @@ class PrivaiteGuardrail(CustomGuardrail):
|
|||
langs = [lang.strip() for lang in self.languages.split(",") if lang.strip()]
|
||||
return langs or ["en"]
|
||||
|
||||
@staticmethod
|
||||
def _parse_block_entities(raw: object) -> list:
|
||||
if isinstance(raw, str):
|
||||
return [e.strip() for e in raw.split(",") if e.strip()]
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(e).strip() for e in raw if str(e).strip()]
|
||||
return []
|
||||
|
||||
async def _engine_for(self, languages: list) -> Any:
|
||||
key = (self.preset, tuple(languages), self.deanonymize)
|
||||
key = (self.preset, tuple(languages), self.deanonymize, tuple(self.block_entities))
|
||||
if self._engine is not None and self._engine_key == key:
|
||||
return self._engine
|
||||
|
||||
|
|
@ -116,12 +131,23 @@ class PrivaiteGuardrail(CustomGuardrail):
|
|||
"Install it with: pip install 'privaite>=0.2.4'"
|
||||
) from exc
|
||||
|
||||
if self.block_entities and "block_entities" not in PIIConfig.model_fields:
|
||||
# PIIConfig uses extra="allow", so an older privaite would silently
|
||||
# swallow block_entities and forward the PII anyway. Fail closed
|
||||
# rather than give a false sense of a policy gate that is not there.
|
||||
raise RuntimeError(
|
||||
"block_entities is set but the installed privaite does not "
|
||||
"support it; upgrade privaite to a version that enforces "
|
||||
"pii.block_entities."
|
||||
)
|
||||
|
||||
config = PIIConfig(
|
||||
enabled=True,
|
||||
preset=self.preset,
|
||||
detectors=DetectorsConfig(presidio=PresidioDetectorConfig(enabled=True, languages=languages)),
|
||||
anonymization=AnonymizationConfig(method="placeholder"),
|
||||
deanonymization=DeanonymizationConfig(enabled=self.deanonymize),
|
||||
block_entities=self.block_entities,
|
||||
)
|
||||
engine = PIIEngine(config)
|
||||
try:
|
||||
|
|
@ -214,7 +240,16 @@ class PrivaiteGuardrail(CustomGuardrail):
|
|||
return data
|
||||
|
||||
engine = await self._engine_for(self._languages())
|
||||
mapping = await self._anonymize_request(data, engine)
|
||||
from privaite.pii.engine import PIIBlockedError
|
||||
|
||||
try:
|
||||
mapping = await self._anonymize_request(data, engine)
|
||||
except PIIBlockedError as exc:
|
||||
# A blocked PII type was found: reject the request outright with a 400,
|
||||
# forwarding nothing. The message names TYPES only, never the values.
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
||||
|
||||
if mapping is not None and self.deanonymize and not mapping.is_empty:
|
||||
# Carry a plain fake->original dict to the post-call hook on the same
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ class PrivaiteGuardrailConfigModel(GuardrailConfigModel):
|
|||
default=True,
|
||||
description="Restore the original PII values in the response.",
|
||||
)
|
||||
block_entities: str | list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"PII types to reject outright instead of masking: a list, or a "
|
||||
"comma-separated string, e.g. ['US_SSN', 'CREDIT_CARD']. A request "
|
||||
"containing any listed type is refused with a 400 and nothing is "
|
||||
"forwarded. Omitted (the default) masks everything and forwards it."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
|
|
|
|||
|
|
@ -37,12 +37,22 @@ class _FakeMapping:
|
|||
self._fakes[fake] = original
|
||||
|
||||
|
||||
class _FakePIIBlockedError(Exception):
|
||||
"""Stand-in for privaite.pii.engine.PIIBlockedError. Names TYPES, not values."""
|
||||
|
||||
def __init__(self, entity_types):
|
||||
self.entity_types = sorted(entity_types)
|
||||
super().__init__("request blocked: contains disallowed PII type(s): " + ", ".join(self.entity_types))
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""Stand-in for privaite.pii.engine.PIIEngine.
|
||||
|
||||
Scrubs the known reals on the way out, restores the known fakes on the way
|
||||
back. ``raise_oserror_until`` lets a test force the spaCy-download retry path
|
||||
in the guardrail's _engine_for by failing the first N initialize() calls.
|
||||
in the guardrail's _engine_for by failing the first N initialize() calls. If
|
||||
``config.block_entities`` names a type present in the text, process_request
|
||||
raises _FakePIIBlockedError, exactly as the real engine would.
|
||||
"""
|
||||
|
||||
raise_oserror_until = 0
|
||||
|
|
@ -82,6 +92,13 @@ class _FakeEngine:
|
|||
new_message = dict(message)
|
||||
new_message["content"] = self._scrub(message.get("content", ""), mapping)
|
||||
anonymized.append(new_message)
|
||||
blocked = set(getattr(self.config, "block_entities", None) or [])
|
||||
if blocked:
|
||||
# placeholder "<EMAIL_ADDRESS_1>" -> type "EMAIL_ADDRESS"
|
||||
detected = {fake.strip("<>").rsplit("_", 1)[0] for fake in mapping.get_all_fakes()}
|
||||
hit = detected & blocked
|
||||
if hit:
|
||||
raise _FakePIIBlockedError(hit)
|
||||
return anonymized, mapping
|
||||
|
||||
async def process_response(self, text, _mapping):
|
||||
|
|
@ -124,6 +141,14 @@ class _Config:
|
|||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
class _PIIConfig(_Config):
|
||||
"""PIIConfig stand-in. ``model_fields`` mirrors a modern privaite that declares
|
||||
block_entities, so the guardrail's fail-closed guard sees the field as present.
|
||||
A test can override model_fields to simulate an older privaite."""
|
||||
|
||||
model_fields = {"preset": None, "languages": None, "block_entities": None}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_privaite_package(monkeypatch):
|
||||
"""Install a fake 'privaite' (and 'spacy') package tree so the guardrail's
|
||||
|
|
@ -142,11 +167,11 @@ def _fake_privaite_package(monkeypatch):
|
|||
AnonymizationConfig=_Config,
|
||||
DeanonymizationConfig=_Config,
|
||||
DetectorsConfig=_Config,
|
||||
PIIConfig=_Config,
|
||||
PIIConfig=_PIIConfig,
|
||||
PresidioDetectorConfig=_Config,
|
||||
)
|
||||
mapping = _module("privaite.pii.mapping", PIIMapping=_FakeMapping)
|
||||
engine = _module("privaite.pii.engine", PIIEngine=_FakeEngine)
|
||||
engine = _module("privaite.pii.engine", PIIEngine=_FakeEngine, PIIBlockedError=_FakePIIBlockedError)
|
||||
buffer = _module("privaite.streaming.buffer", StreamingDeAnonymizer=_FakeStreamingDeAnonymizer)
|
||||
config_pkg = _module("privaite.config", schema=schema)
|
||||
pii_pkg = _module("privaite.pii", mapping=mapping, engine=engine)
|
||||
|
|
@ -634,3 +659,79 @@ async def test_post_call_failure_hook_pops_map():
|
|||
# missing metadata and a non-dict request_data are safe no-ops
|
||||
assert await gr.async_post_call_failure_hook({}, RuntimeError("x"), None) is None
|
||||
assert await gr.async_post_call_failure_hook(None, RuntimeError("x"), None) is None
|
||||
|
||||
|
||||
def test_block_entities_config_model_field():
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.privaite import (
|
||||
PrivaiteGuardrailConfigModel,
|
||||
)
|
||||
|
||||
assert PrivaiteGuardrailConfigModel.model_fields["block_entities"].default is None
|
||||
|
||||
|
||||
def test_block_entities_parsed_from_string_and_list():
|
||||
assert _make_guardrail(block_entities="US_SSN, CREDIT_CARD").block_entities == [
|
||||
"US_SSN",
|
||||
"CREDIT_CARD",
|
||||
]
|
||||
assert _make_guardrail(block_entities=["EMAIL_ADDRESS"]).block_entities == ["EMAIL_ADDRESS"]
|
||||
assert _make_guardrail().block_entities == []
|
||||
|
||||
|
||||
def test_initialize_guardrail_passes_block_entities():
|
||||
from litellm.proxy.guardrails.guardrail_hooks.privaite import initialize_guardrail
|
||||
|
||||
litellm_params = types.SimpleNamespace(
|
||||
mode="pre_call",
|
||||
default_on=False,
|
||||
preset="light",
|
||||
languages="en",
|
||||
deanonymize=True,
|
||||
block_entities=["US_SSN"],
|
||||
)
|
||||
callback = initialize_guardrail(litellm_params, {"guardrail_name": "privaite"})
|
||||
assert callback.block_entities == ["US_SSN"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_entities_rejects_with_400():
|
||||
from fastapi import HTTPException
|
||||
|
||||
gr = _make_guardrail(block_entities=["EMAIL_ADDRESS"])
|
||||
data = {"messages": [{"role": "user", "content": "reach marie@acme.com"}]}
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
await gr.async_pre_call_hook(None, None, data, "completion")
|
||||
|
||||
assert ei.value.status_code == 400
|
||||
detail = ei.value.detail
|
||||
msg = detail["error"] if isinstance(detail, dict) else str(detail)
|
||||
assert "EMAIL_ADDRESS" in msg
|
||||
assert "marie@acme.com" not in msg # the value never leaks into the error
|
||||
assert "privaite_map" not in (data.get("metadata") or {}) # nothing forwarded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_entities_ignores_types_not_present():
|
||||
# a blocked type that is absent must not disturb a normal request: the other
|
||||
# PII is still masked and the request goes through with a restore map.
|
||||
gr = _make_guardrail(block_entities=["US_SSN"])
|
||||
data = {"messages": [{"role": "user", "content": "I am Marie Dupont, marie@acme.com"}]}
|
||||
out = await gr.async_pre_call_hook(None, None, data, "completion")
|
||||
|
||||
serialized = str(out["messages"])
|
||||
assert "Marie Dupont" not in serialized
|
||||
assert "marie@acme.com" not in serialized
|
||||
assert out.get("metadata", {}).get("privaite_map")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_entities_fails_closed_when_privaite_too_old(monkeypatch):
|
||||
# simulate an older privaite whose PIIConfig has no block_entities field:
|
||||
# extra="allow" would swallow it silently, so the guardrail must refuse.
|
||||
schema = sys.modules["privaite.config.schema"]
|
||||
monkeypatch.setattr(schema.PIIConfig, "model_fields", {"preset": None, "languages": None})
|
||||
|
||||
gr = _make_guardrail(block_entities=["EMAIL_ADDRESS"])
|
||||
data = {"messages": [{"role": "user", "content": "marie@acme.com"}]}
|
||||
with pytest.raises(RuntimeError, match="block_entities"):
|
||||
await gr.async_pre_call_hook(None, None, data, "completion")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue