fix(guardrails): read the /bedrock body in the Lakera v2 guardrail only on pass-through calls

A chat or responses request carrying its own custom_llm_provider, endpoint and
data keys was read as a /bedrock body, so mask blocked it and monitor stopped
masking its instructions. The reader now also requires the proxy-set
allm_passthrough_route call type.

Adds integration cells for Anthropic tool results, a during_call guardrail and a
Lakera outage on /bedrock.
This commit is contained in:
yucheng 2026-10-31 11:42:23 -07:00
parent f6c0673592
commit 29cffedd8b
3 changed files with 173 additions and 27 deletions

View file

@ -42,7 +42,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.lakera_ai_v2 import (
LakeraAIRequest,
LakeraAIResponse,
)
from litellm.types.utils import CallTypesLiteral, GuardrailStatus, ModelResponse
from litellm.types.utils import CallTypes, CallTypesLiteral, GuardrailStatus, ModelResponse
_DECODED_JSON: Final = TypeAdapter(object)
_RESPONSE_FIELDS: Final = TypeAdapter(Mapping[str, object])
@ -203,6 +203,7 @@ def _unmaskable_reason(
guardrail: "LakeraAIGuardrail",
data: dict[str, object],
lakera_response: LakeraAIResponse | None,
call_type: str,
) -> str | None:
"""Why a PII-only violation on ``data`` can't be masked in place, or None when it can."""
if has_non_string_content(data):
@ -211,7 +212,7 @@ def _unmaskable_reason(
return "messages and input are both present, so the write-back is positionally ambiguous"
if "messages" in data and not isinstance(data.get("messages"), list):
return "a messages key that isn't a list, so there's nothing to merge the redacted content into"
if _bedrock_passthrough_body(data) is not None:
if _bedrock_passthrough_body(data, call_type) is not None:
return "a /bedrock pass-through body, which has no write-back path for the redacted content"
if not _has_responses_instructions(guardrail, data):
return "no write-back path for the redacted content"
@ -220,9 +221,14 @@ def _unmaskable_reason(
return None
def _bedrock_passthrough_body(data: Mapping[str, object]) -> Mapping[str, object] | None:
"""The client's request body on a /bedrock pass-through call, which the route nests under ``data``."""
if data.get("custom_llm_provider") != "bedrock" or not isinstance(data.get("endpoint"), str):
def _bedrock_passthrough_body(data: Mapping[str, object], call_type: str | None) -> Mapping[str, object] | None:
"""The client's request body on a /bedrock pass-through call (call type ``allm_passthrough_route``), which the
route nests under ``data``."""
if (
call_type != CallTypes.allm_passthrough_route.value
or data.get("custom_llm_provider") != "bedrock"
or not isinstance(data.get("endpoint"), str)
):
return None
return as_str_mapping(data.get("data"))
@ -268,11 +274,11 @@ def _bedrock_turns(message: object) -> Sequence[Mapping[str, object]]:
]
def _bedrock_inspection_messages(data: Mapping[str, object]) -> Sequence[Mapping[str, str]]:
def _bedrock_inspection_messages(data: Mapping[str, object], call_type: str | None) -> Sequence[Mapping[str, str]]:
"""Chat-style messages for a /bedrock invoke or Converse body: ``system`` first as a system message, then each
``messages`` turn with its text parts joined and its tool results as tool messages. Turns with no text are dropped.
"""
body: Final = _bedrock_passthrough_body(data)
body: Final = _bedrock_passthrough_body(data, call_type)
if body is None:
return []
system: Final = body.get("system")
@ -283,7 +289,9 @@ def _bedrock_inspection_messages(data: Mapping[str, object]) -> Sequence[Mapping
)
def _build_lakera_inspection_messages(data: Mapping[str, object]) -> Sequence[Mapping[str, str]]:
def _build_lakera_inspection_messages(
data: Mapping[str, object], *, call_type: str | None = None
) -> Sequence[Mapping[str, str]]:
"""Like build_inspection_messages, but also covers the Responses-API
``instructions`` field, placed first since litellm later converts it
into the model's leading system message and a prompt-injection detector
@ -304,7 +312,7 @@ def _build_lakera_inspection_messages(data: Mapping[str, object]) -> Sequence[Ma
)
return [
*leading,
*_bedrock_inspection_messages(data),
*_bedrock_inspection_messages(data, call_type),
*build_inspection_messages(dict(data)),
]
@ -580,6 +588,7 @@ class LakeraAIGuardrail(CustomGuardrail):
inspected_messages: Sequence[AllMessageValues],
lakera_response: LakeraAIResponse | None,
masked_entity_count: dict[str, int],
call_type: str,
) -> bool:
"""Mask a body whose only obstacle to mask-in-place is the Responses-API
``instructions`` field, writing the redacted instructions straight into
@ -587,7 +596,7 @@ class LakeraAIGuardrail(CustomGuardrail):
that field and would fold the instructions text into ``data["input"]``.
Returns False without masking anything when _unmaskable_reason names an
obstacle this can't get around."""
if _unmaskable_reason(self, data, lakera_response) is not None:
if _unmaskable_reason(self, data, lakera_response, call_type) is not None:
return False
redacted: Final = self._mask_pii_in_messages(
messages=inspected_messages,
@ -619,7 +628,7 @@ class LakeraAIGuardrail(CustomGuardrail):
return data
# Covers multimodal list content + Responses-API input/instructions.
inspection_messages: Final = _build_lakera_inspection_messages(data)
inspection_messages: Final = _build_lakera_inspection_messages(data, call_type=call_type)
if not inspection_messages:
verbose_proxy_logger.warning("Lakera AI: not running guardrail. No inspectable text in data")
return data
@ -646,7 +655,7 @@ class LakeraAIGuardrail(CustomGuardrail):
# handled safely by _apply_redacted_messages_back_preserving_fields's
# scope-index merge, which never touches a message outside the scope
# it actually redacted instead of reconstructing the list from scratch.
is_bedrock_body: Final = _bedrock_passthrough_body(data) is not None # pyright: ignore[reportUnknownArgumentType] # hook data is an untyped dict
is_bedrock_body: Final = _bedrock_passthrough_body(data, call_type) is not None # pyright: ignore[reportUnknownArgumentType] # hook data is an untyped dict
is_multimodal_input: Final = (
has_non_string_content(data)
or _has_combined_messages_and_input(data)
@ -720,6 +729,7 @@ class LakeraAIGuardrail(CustomGuardrail):
inspected_messages=new_messages,
lakera_response=lakera_guardrail_response,
masked_entity_count=masked_entity_count,
call_type=call_type,
)
if masked_in_place:
verbose_proxy_logger.warning(
@ -729,7 +739,7 @@ class LakeraAIGuardrail(CustomGuardrail):
verbose_proxy_logger.error(
"Lakera Guardrail: Monitoring mode - PII detected but NOT masked, forwarding unredacted "
"content to the model (reason: %s)",
_unmaskable_reason(self, data, lakera_guardrail_response),
_unmaskable_reason(self, data, lakera_guardrail_response, call_type),
)
else:
verbose_proxy_logger.warning(
@ -760,7 +770,7 @@ class LakeraAIGuardrail(CustomGuardrail):
return
# Covers multimodal list content + Responses-API input/instructions.
inspection_messages: Final = _build_lakera_inspection_messages(data)
inspection_messages: Final = _build_lakera_inspection_messages(data, call_type=call_type)
if not inspection_messages:
verbose_proxy_logger.warning("Lakera AI: not running guardrail. No inspectable text in data")
return

View file

@ -83,6 +83,7 @@ def _bedrock_reply(request: Request) -> Reply:
class BedrockLakeraRig:
owned: OwnedProxy
lakera: Wire
lakera_down: Wire
upstream: Wire
@ -91,6 +92,7 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I
directory: Final = tmp_path_factory.mktemp("lakera-bedrock-passthrough")
with ExitStack() as stack:
lakera: Final = stack.enter_context(wire_server(_lakera_verdict))
lakera_down: Final = stack.enter_context(wire_server(lambda _: Reply(status=500, body=b'{"error":"down"}')))
upstream: Final = stack.enter_context(wire_server(_bedrock_reply))
config: Final = _JSON.validate_python(yaml.safe_load(_CONFIG.read_text()))
aws: Final = {
@ -127,12 +129,36 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I
},
}
for on_flagged in ON_FLAGGED
]
+ [
{
"guardrail_name": "lakera-during",
"litellm_params": {
"guardrail": "lakera_v2",
"mode": "during_call",
"default_on": False,
"on_flagged": "block",
"api_base": lakera.url,
"api_key": "synthetic-lakera-key",
},
},
{
"guardrail_name": "lakera-down",
"litellm_params": {
"guardrail": "lakera_v2",
"mode": "pre_call",
"default_on": False,
"on_flagged": "block",
"api_base": lakera_down.url,
"api_key": "synthetic-lakera-key",
},
},
],
}
)
)
owned: Final = stack.enter_context(owned_proxy_process(session_gateway, directory, {}, config=path))
yield BedrockLakeraRig(owned, lakera, upstream)
yield BedrockLakeraRig(owned, lakera, lakera_down, upstream)
def _invoke_body(prompt: str, system: str = SYSTEM) -> dict[str, JsonValue]:
@ -164,11 +190,12 @@ def _body(action: str, prompt: str, system: str = SYSTEM) -> dict[str, JsonValue
return _converse_body(prompt, system) if action.startswith("converse") else _invoke_body(prompt, system)
def _send(rig: BedrockLakeraRig, on_flagged: str, action: str, body: dict[str, JsonValue]) -> httpx.Response:
def _send(rig: BedrockLakeraRig, guardrail: str, action: str, body: dict[str, JsonValue]) -> httpx.Response:
gateway: Final = rig.owned.gateway
with gateway.scenario() as scenario:
key: Final = scenario.key(metadata={"guardrails": [f"lakera-{on_flagged}"]})
key: Final = scenario.key(metadata={"guardrails": [f"lakera-{guardrail}"]})
rig.lakera.drain()
rig.lakera_down.drain()
rig.upstream.drain()
return gateway.request("POST", f"/bedrock/model/{MODEL}/{action}", body, key=key)
@ -225,7 +252,7 @@ def test_card_in_the_system_prompt_is_sent_to_lakera(rig: BedrockLakeraRig) -> N
ids=["guard-content", "tool-result-json"],
)
def test_card_in_a_nested_converse_block_is_blocked(rig: BedrockLakeraRig, block: dict[str, JsonValue]) -> None:
body: Final = {
body: Final[dict[str, JsonValue]] = {
**_converse_body("Hi again"),
"messages": [{"role": "user", "content": [{"text": "Hi again"}, block]}],
}
@ -251,3 +278,68 @@ def test_monitor_mode_forwards_the_body_unchanged(rig: BedrockLakeraRig) -> None
assert response.status_code == 200, response.text
assert len(_lakera_messages(rig)) == 1
assert [_JSON.validate_json(request.body) for request in rig.upstream.drain()] == [body]
@pytest.mark.parametrize(
"content",
[PII_PROMPT, [{"type": "text", "text": PII_PROMPT}]],
ids=["string", "text-blocks"],
)
def test_card_in_an_anthropic_tool_result_is_blocked(rig: BedrockLakeraRig, content: JsonValue) -> None:
body: Final[dict[str, JsonValue]] = {
**_invoke_body("Hi"),
"messages": [
{"role": "user", "content": "Look up my billing details."},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "billing", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": content}]},
],
}
response: Final = _send(rig, "block", "invoke", body)
assert response.status_code == 400, response.text
assert [role for role, text in _lakera_messages(rig)[0] if CARD in text] == ["tool"]
assert rig.upstream.drain() == ()
@pytest.mark.parametrize("action", ["invoke", "converse"])
def test_during_call_guardrail_reads_the_bedrock_body(rig: BedrockLakeraRig, action: str) -> None:
response: Final = _send(rig, "during", action, _body(action, PII_PROMPT))
assert response.status_code == 400, response.text
assert ("user", PII_PROMPT) in _lakera_messages(rig)[0]
@pytest.mark.parametrize("action", ["invoke", "converse"])
def test_lakera_outage_fails_the_bedrock_request_closed(rig: BedrockLakeraRig, action: str) -> None:
response: Final = _send(rig, "down", action, _body(action, "Hi again"))
assert response.status_code == 500, response.text
assert len(rig.lakera_down.drain()) >= 1
assert rig.upstream.drain() == ()
def test_a_chat_call_carrying_bedrock_keys_is_masked_not_blocked(rig: BedrockLakeraRig) -> None:
gateway: Final = rig.owned.gateway
with gateway.scenario() as scenario:
key: Final = scenario.key(metadata={"guardrails": ["lakera-mask"]})
rig.lakera.drain()
rig.upstream.drain()
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{
"model": MODEL,
"messages": [{"role": "user", "content": PII_PROMPT}],
"custom_llm_provider": "bedrock",
"endpoint": f"/model/{MODEL}/converse",
"data": _converse_body("Hi"),
},
key=key,
)
assert response.status_code == 200, response.text
assert _lakera_messages(rig) == ((("user", PII_PROMPT),),)
sent: Final = b"".join(request.body for request in rig.upstream.drain()).decode()
assert "Please update the billing" in sent
assert CARD not in sent

View file

@ -27,7 +27,7 @@ from litellm.proxy.guardrails.guardrail_hooks.lakera_ai_v2 import (
humanize_lakera_block_reasons,
)
from litellm.types.guardrails import LitellmParams, Mode
from litellm.types.utils import ModelResponse
from litellm.types.utils import CallTypesLiteral, ModelResponse
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
@ -2483,7 +2483,10 @@ def _converse_body() -> dict[str, object]:
"role": "user",
"content": [{"text": "hello world"}, {"image": {"format": "png", "source": {"bytes": "AA"}}}],
},
{"role": "assistant", "content": [{"text": "hi"}, {"toolUse": {"toolUseId": "t1", "name": "f", "input": {}}}]},
{
"role": "assistant",
"content": [{"text": "hi"}, {"toolUse": {"toolUseId": "t1", "name": "f", "input": {}}}],
},
{"role": "user", "content": [{"toolResult": {"toolUseId": "t1", "content": [{"text": "card on file"}]}}]},
],
}
@ -2513,6 +2516,11 @@ BEDROCK_PII_LAKERA_RESPONSE: Final = {
"breakdown": [{"detector_type": "pii/credit_card", "detected": True, "message_id": 1}],
"payload": [{"detector_type": "pii/credit_card", "start": 0, "end": 5, "message_id": 1}],
}
EMAIL_AT_START_LAKERA_RESPONSE: Final = {
"flagged": True,
"breakdown": [{"detector_type": "pii/email", "detected": True, "message_id": 0}],
"payload": [{"detector_type": "pii/email", "start": 0, "end": 7, "message_id": 0}],
}
BEDROCK_PROMPT_ATTACK_LAKERA_RESPONSE: Final = {
"flagged": True,
"breakdown": [{"detector_type": "prompt_attack", "detected": True, "message_id": 1}],
@ -2520,7 +2528,10 @@ BEDROCK_PROMPT_ATTACK_LAKERA_RESPONSE: Final = {
async def _run_bedrock_pre_call(
guardrail: LakeraAIGuardrail, lakera_response: Mapping[str, object], data: dict[str, object]
guardrail: LakeraAIGuardrail,
lakera_response: Mapping[str, object],
data: dict[str, object],
call_type: CallTypesLiteral = "allm_passthrough_route",
) -> Sequence[object]:
"""Run the pre-call hook against a Lakera double and return the messages each guard request carried."""
sent: Final = list[object]() # mutable-ok: the Lakera double records each guard request it receives
@ -2534,14 +2545,16 @@ async def _run_bedrock_pre_call(
user_api_key_dict=UserAPIKeyAuth(api_key="test_key"),
cache=MagicMock(),
data=data,
call_type="allm_passthrough_route",
call_type=call_type,
)
return sent
class TestBedrockPassthroughBody:
def test_reads_converse_system_text_and_tool_results(self):
assert _build_lakera_inspection_messages(_bedrock_data(_converse_body())) == [
assert _build_lakera_inspection_messages(
_bedrock_data(_converse_body()), call_type="allm_passthrough_route"
) == [
{"role": "system", "content": "be nice"},
{"role": "user", "content": "hello world"},
{"role": "assistant", "content": "hi"},
@ -2549,7 +2562,7 @@ class TestBedrockPassthroughBody:
]
def test_reads_anthropic_invoke_system_text_and_tool_results(self):
assert _build_lakera_inspection_messages(_bedrock_data(_invoke_body())) == [
assert _build_lakera_inspection_messages(_bedrock_data(_invoke_body()), call_type="allm_passthrough_route") == [
{"role": "system", "content": "be nice"},
{"role": "user", "content": "hello world"},
{"role": "assistant", "content": "hi"},
@ -2568,7 +2581,7 @@ class TestBedrockPassthroughBody:
},
],
}
assert _build_lakera_inspection_messages(_bedrock_data(body)) == [
assert _build_lakera_inspection_messages(_bedrock_data(body), call_type="allm_passthrough_route") == [
{"role": "system", "content": "system card"},
{"role": "user", "content": "hello\nguarded card"},
{"role": "tool", "content": '{"card": "on file"}'},
@ -2576,13 +2589,44 @@ class TestBedrockPassthroughBody:
def test_a_nested_body_for_another_provider_is_not_read(self):
data: Final = {**_bedrock_data(_invoke_body()), "custom_llm_provider": "vertex_ai"}
assert _build_lakera_inspection_messages(data) == []
assert _build_lakera_inspection_messages(data, call_type="allm_passthrough_route") == []
@pytest.mark.parametrize("call_type", ["acompletion", "aresponses", "anthropic_messages", None])
def test_bedrock_keys_on_another_call_type_are_not_read(self, call_type: str | None):
data: Final = {**_bedrock_data(_invoke_body()), "messages": [USER_MSG]}
assert _build_lakera_inspection_messages(data, call_type=call_type) == [USER_MSG]
@pytest.mark.asyncio
async def test_a_chat_call_carrying_bedrock_keys_is_still_masked(self):
data: Final = {**_bedrock_data(_invoke_body()), "messages": [{"role": "user", "content": "a@b.com hi"}]}
sent: Final = await _run_bedrock_pre_call(
LakeraAIGuardrail(api_key="test_key", on_flagged="mask"),
EMAIL_AT_START_LAKERA_RESPONSE,
data,
"acompletion",
)
assert sent == [[{"role": "user", "content": "a@b.com hi"}]]
assert data["messages"] == [{"role": "user", "content": "[MASKED EMAIL] hi"}]
assert data["data"] == _invoke_body()
@pytest.mark.asyncio
async def test_monitor_still_masks_responses_instructions_next_to_bedrock_keys(self):
data: Final = {**_bedrock_data(_invoke_body()), "instructions": "a@b.com is the contact", "input": "hi"}
await _run_bedrock_pre_call(
LakeraAIGuardrail(api_key="test_key", on_flagged="monitor"),
EMAIL_AT_START_LAKERA_RESPONSE,
data,
"responses",
)
assert data["instructions"] == "[MASKED EMAIL] is the contact"
@pytest.mark.asyncio
async def test_clean_body_reaches_lakera_and_is_forwarded_unchanged(self):
data: Final = _bedrock_data(_converse_body())
sent: Final = await _run_bedrock_pre_call(LakeraAIGuardrail(api_key="test_key"), {"flagged": False}, data)
assert sent == [_build_lakera_inspection_messages(_bedrock_data(_converse_body()))]
assert sent == [
_build_lakera_inspection_messages(_bedrock_data(_converse_body()), call_type="allm_passthrough_route")
]
assert data["data"] == _converse_body()
@pytest.mark.asyncio