From 2e1929787f01a52a40f23ffa19ff4be89275dee2 Mon Sep 17 00:00:00 2001 From: albertbausili Date: Fri, 16 Oct 2026 09:27:44 +0200 Subject: [PATCH 01/13] fix(guardrails): use authenticated identity for direct evaluation --- .../proxy/guardrails/guardrail_endpoints.py | 31 ++++++++- .../guardrails/test_guardrail_endpoints.py | 66 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 94a9d7c2bdf..f2c553b52e7 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, Union, from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH @@ -2318,6 +2318,26 @@ async def test_custom_code_guardrail( ) +_GUARDRAIL_METADATA_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + + +def _metadata_fields(value: object) -> Mapping[str, object]: + try: + return _GUARDRAIL_METADATA_ADAPTER.validate_python(value) + except ValidationError: + return {} + + +def _guardrail_request_metadata(caller: object, proxy: object) -> Mapping[str, object]: + caller_fields: Final = tuple( + (key, value) for key, value in _metadata_fields(caller).items() if not key.startswith("user_api_key_") + ) + key_identity: Final = tuple( + (key, value) for key, value in _metadata_fields(proxy).items() if key.startswith("user_api_key_") + ) + return dict((*caller_fields, *key_identity)) + + def _execution_timeout_response(timeout: float) -> TestCustomCodeGuardrailResponse: return TestCustomCodeGuardrailResponse( success=False, @@ -2497,9 +2517,14 @@ async def apply_guardrail( if litellm_logging_obj is not None: _patch_logging_obj_for_guardrail(litellm_logging_obj, request) + metadata: Final = _guardrail_request_metadata(request.metadata, data.get("metadata")) request_data: Final[dict] = { - **({"messages": request.messages} if request.messages is not None else {}), - **({"metadata": request.metadata} if request.metadata is not None else {}), + key: value + for key, value in ( + ("messages", request.messages), + ("metadata", metadata if request.metadata is not None or metadata else None), + ) + if value is not None } _input_type: Final = _resolve_guardrail_input_type(active_guardrail, request.input_type) guardrailed_inputs: Final = await active_guardrail.apply_guardrail( diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 13049093d6c..3d5087d6d1e 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2127,7 +2127,7 @@ async def test_apply_guardrail_invokes_logging_pipeline(mocker): } -def _patch_apply_guardrail_env(mocker, guardrail_result): +def _patch_apply_guardrail_env(mocker, guardrail_result, processed_data=None): mock_guardrail = mocker.Mock() mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result) @@ -2140,7 +2140,7 @@ def _patch_apply_guardrail_env(mocker, guardrail_result): mock_logging_obj.model_call_details = {} mock_processor = mocker.Mock() mock_processor.common_processing_pre_call_logic = AsyncMock( - return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj) + return_value=(processed_data or {"guardrail_name": "test-guardrail"}, mock_logging_obj) ) mocker.patch( "litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing", @@ -2211,6 +2211,68 @@ async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker): ) +@pytest.mark.asyncio +async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_key(mocker): + """Identity fields come from the proxy's own sanitized metadata, never from + the caller, so a request cannot impersonate another key or probe its policy.""" + mock_guardrail = _patch_apply_guardrail_env( + mocker, + {"texts": ["ok"]}, + processed_data={ + "guardrail_name": "test-guardrail", + "metadata": { + "route": "/apply_guardrail", + "user_api_key_alias": "billing-app", + "user_api_key_user_id": "u-1", + }, + }, + ) + + request = ApplyGuardrailRequest( + guardrail_name="test-guardrail", + text="hello", + metadata={ + "forbidden_topics": ["tax"], + "user_api_key_alias": "someone-else", + "user_api_key_user_email": "victim@example.com", + }, + ) + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), + ) + + forwarded = mock_guardrail.apply_guardrail.await_args.kwargs["request_data"]["metadata"] + assert forwarded == { + "forbidden_topics": ["tax"], + "user_api_key_alias": "billing-app", + "user_api_key_user_id": "u-1", + } + + +@pytest.mark.asyncio +async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metadata(mocker): + """A caller that sends no metadata still gets the authenticated identity + forwarded, the same shape every LLM route gives a guardrail.""" + mock_guardrail = _patch_apply_guardrail_env( + mocker, + {"texts": ["ok"]}, + processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, + ) + + request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello") + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), + ) + + assert mock_guardrail.apply_guardrail.await_args.kwargs["request_data"] == { + "metadata": {"user_api_key_alias": "billing-app"} + } + + @pytest.mark.asyncio async def test_apply_guardrail_omits_metadata_when_not_sent(mocker): """Without metadata, request_data stays empty (backward-compatible).""" From d61c6c4d0de5983fa918ac158665b9553cb08d58 Mon Sep 17 00:00:00 2001 From: albertbausili Date: Fri, 16 Oct 2026 09:33:39 +0200 Subject: [PATCH 02/13] fix(guardrails): validate processed metadata before identity selection --- litellm/proxy/guardrails/guardrail_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index f2c553b52e7..c66fe78dbdb 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2517,7 +2517,9 @@ async def apply_guardrail( if litellm_logging_obj is not None: _patch_logging_obj_for_guardrail(litellm_logging_obj, request) - metadata: Final = _guardrail_request_metadata(request.metadata, data.get("metadata")) + metadata: Final = _guardrail_request_metadata( + request.metadata, _GUARDRAIL_METADATA_ADAPTER.validate_python(data).get("metadata") + ) request_data: Final[dict] = { key: value for key, value in ( From ec2969f7fe7c70cb4486eb5e5424ee1d040f2505 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Wed, 28 Oct 2026 23:45:22 +0100 Subject: [PATCH 03/13] fix(guardrails): reserve the bare user_api_key metadata field --- .../proxy/guardrails/guardrail_endpoints.py | 10 +++++-- .../guardrails/test_guardrail_endpoints.py | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index c66fe78dbdb..085c4aff5c2 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2328,14 +2328,18 @@ def _metadata_fields(value: object) -> Mapping[str, object]: return {} +def _is_key_identity_field(key: str) -> bool: + return key == "user_api_key" or key.startswith("user_api_key_") + + def _guardrail_request_metadata(caller: object, proxy: object) -> Mapping[str, object]: caller_fields: Final = tuple( - (key, value) for key, value in _metadata_fields(caller).items() if not key.startswith("user_api_key_") + (key, value) for key, value in _metadata_fields(caller).items() if not _is_key_identity_field(key) ) key_identity: Final = tuple( - (key, value) for key, value in _metadata_fields(proxy).items() if key.startswith("user_api_key_") + (key, value) for key, value in _metadata_fields(proxy).items() if _is_key_identity_field(key) ) - return dict((*caller_fields, *key_identity)) + return MappingProxyType(dict((*caller_fields, *key_identity))) def _execution_timeout_response(timeout: float) -> TestCustomCodeGuardrailResponse: diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 3d5087d6d1e..17487b846f8 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2251,6 +2251,34 @@ async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_k } +@pytest.mark.asyncio +async def test_apply_guardrail_ignores_a_caller_supplied_user_api_key(mocker): + """The bare key field carries attribution as much as the prefixed ones, so a caller + cannot set it either.""" + mock_guardrail = _patch_apply_guardrail_env( + mocker, + {"texts": ["ok"]}, + processed_data={ + "guardrail_name": "test-guardrail", + "metadata": {"route": "/apply_guardrail", "user_api_key": "hash-of-the-real-key"}, + }, + ) + + request = ApplyGuardrailRequest( + guardrail_name="test-guardrail", + text="hello", + metadata={"forbidden_topics": ["tax"], "user_api_key": "hash-of-someone-else"}, + ) + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=request, + user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), + ) + + forwarded = mock_guardrail.apply_guardrail.await_args.kwargs["request_data"]["metadata"] + assert forwarded == {"forbidden_topics": ["tax"], "user_api_key": "hash-of-the-real-key"} + + @pytest.mark.asyncio async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metadata(mocker): """A caller that sends no metadata still gets the authenticated identity From 8378cd84916a7758b0d9980bf2114ee5b5c1b95a Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Fri, 30 Oct 2026 11:30:45 +0100 Subject: [PATCH 04/13] fix(guardrails): hand /apply_guardrail guardrails metadata they can write to The metadata built from the authenticated key was a read-only mapping, so a guardrail that annotates it, as OpenAI moderation does, raised a TypeError and the endpoint answered 500. Return a plain dict. --- .../proxy/guardrails/guardrail_endpoints.py | 11 ++++-- .../guardrails/test_guardrail_endpoints.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 0df2bdf28fe..c8657445d8d 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2346,14 +2346,21 @@ def _is_key_identity_field(key: str) -> bool: return key == "user_api_key" or key.startswith("user_api_key_") -def _guardrail_request_metadata(caller: object, proxy: object) -> Mapping[str, object]: +def _guardrail_request_metadata( + caller: object, proxy: object +) -> dict[str, object]: # mutable-ok: guardrails annotate the metadata they are handed + """Caller metadata with the identity fields taken from the authenticated key. + + The result is handed to the guardrail as ``request_data["metadata"]``, which providers + write their own detection details into, so it cannot be a read-only mapping. + """ caller_fields: Final = tuple( (key, value) for key, value in _metadata_fields(caller).items() if not _is_key_identity_field(key) ) key_identity: Final = tuple( (key, value) for key, value in _metadata_fields(proxy).items() if _is_key_identity_field(key) ) - return MappingProxyType(dict((*caller_fields, *key_identity))) + return dict((*caller_fields, *key_identity)) def _execution_timeout_response(timeout: float) -> TestCustomCodeGuardrailResponse: diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index ac1a99b3ec9..325b7d7dd89 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2291,6 +2291,42 @@ async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_k } +@pytest.mark.asyncio +async def test_apply_guardrail_hands_the_guardrail_metadata_it_can_write_to(mocker): + """Guardrails such as OpenAI moderation annotate the metadata they are given, so a + read-only mapping turns a normal evaluation into a 500.""" + mock_guardrail = _patch_apply_guardrail_env( + mocker, + {"texts": ["ok"]}, + processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, + ) + + annotated: Final[list[object]] = [] # mutable-ok: records the metadata the guardrail was handed + + async def annotating_guardrail(**kwargs): + kwargs["request_data"]["metadata"]["moderation_model"] = "omni-moderation-latest" + annotated.append(kwargs["request_data"]["metadata"]) + return {"texts": ["ok"]} + + mock_guardrail.apply_guardrail = annotating_guardrail + + await apply_guardrail( + fastapi_request=mocker.Mock(), + request=ApplyGuardrailRequest( + guardrail_name="test-guardrail", text="hello", metadata={"forbidden_topics": ["tax"]} + ), + user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), + ) + + assert annotated == [ + { + "forbidden_topics": ["tax"], + "user_api_key_alias": "billing-app", + "moderation_model": "omni-moderation-latest", + } + ] + + @pytest.mark.asyncio async def test_apply_guardrail_ignores_a_caller_supplied_user_api_key(mocker): """The bare key field carries attribution as much as the prefixed ones, so a caller From 1a9951222e32b8ace02832b86abafa0cca1c9840 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Sat, 31 Oct 2026 23:17:53 +0100 Subject: [PATCH 05/13] fix(guardrails): let the caller keep the end user and route on /apply_guardrail The reserved namespace matched every user_api_key_ field, so the caller's end user id was dropped with nothing in its place and the proxy's own route replaced the one the call came from, which left the tool policy guardrail with no tool names to block on. Reserve only the fields that attribute the key, and cover the decision with tests that use no doubles. --- .../proxy/guardrails/guardrail_endpoints.py | 12 +++ .../guardrails/test_guardrail_endpoints.py | 90 ++++++++++--------- 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index c8657445d8d..4da0b634e22 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2342,7 +2342,19 @@ def _metadata_fields(value: object) -> Mapping[str, object]: return {} +_CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id", "user_api_key_request_route"}) + + def _is_key_identity_field(key: str) -> bool: + """Whether a metadata field attributes the authenticated key rather than the request. + + The ``user_api_key_`` namespace also carries two fields that describe the caller's own + request: the end user it is made for, and the route it came from. A guardrail needs both + to decide, and on ``/apply_guardrail`` the proxy's own values say nothing about the call + the caller is asking about. + """ + if key in _CALLER_OWNED_KEY_FIELDS: + return False return key == "user_api_key" or key.startswith("user_api_key_") diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 325b7d7dd89..6a72e847c47 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -4,9 +4,10 @@ from collections.abc import Callable from datetime import datetime from types import MappingProxyType from typing import Dict, Final, List, Optional -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest +from pytest_mock import MockerFixture import yaml @@ -22,6 +23,7 @@ from litellm.proxy.guardrails.guardrail_endpoints import ( TestCustomCodeGuardrailRequest, UpdateGuardrailRequest, _get_guardrails_list_response, + _guardrail_request_metadata, apply_guardrail, approve_guardrail_submission, create_guardrail, @@ -2167,18 +2169,20 @@ async def test_apply_guardrail_invokes_logging_pipeline(mocker): } -def _patch_apply_guardrail_env(mocker, guardrail_result, processed_data=None): - mock_guardrail = mocker.Mock() +def _patch_apply_guardrail_env( + mocker: MockerFixture, guardrail_result: object, processed_data: dict[str, object] | None = None +) -> Mock: + mock_guardrail: Final = mocker.Mock() mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result) - mock_registry = mocker.Mock() + mock_registry: Final = mocker.Mock() mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail mocker.patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY", mock_registry) - mock_logging_obj = mocker.Mock() + mock_logging_obj: Final = mocker.Mock() mock_logging_obj.async_success_handler = AsyncMock() mock_logging_obj.model_call_details = {} - mock_processor = mocker.Mock() + mock_processor: Final = mocker.Mock() mock_processor.common_processing_pre_call_logic = AsyncMock( return_value=(processed_data or {"guardrail_name": "test-guardrail"}, mock_logging_obj) ) @@ -2187,7 +2191,7 @@ def _patch_apply_guardrail_env(mocker, guardrail_result, processed_data=None): return_value=mock_processor, ) - mock_proxy_logging = mocker.Mock() + mock_proxy_logging: Final = mocker.Mock() mock_proxy_logging.post_call_success_hook = AsyncMock() mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging) mocker.patch("litellm.proxy.proxy_server.general_settings", {}) @@ -2251,6 +2255,42 @@ async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker): ) +class TestGuardrailRequestMetadata: + """What a guardrail is told about the caller on /apply_guardrail.""" + + def test_the_authenticated_key_identity_replaces_the_caller_version(self) -> None: + metadata: Final = _guardrail_request_metadata( + {"forbidden_topics": ["tax"], "user_api_key_alias": "someone-else"}, + {"user_api_key_alias": "billing-app", "user_api_key_user_id": "u-1"}, + ) + assert metadata == { + "forbidden_topics": ["tax"], + "user_api_key_alias": "billing-app", + "user_api_key_user_id": "u-1", + } + + def test_the_bare_key_hash_cannot_come_from_the_caller(self) -> None: + metadata: Final = _guardrail_request_metadata({"user_api_key": "spoof"}, {"user_api_key": "hashed"}) + assert metadata == {"user_api_key": "hashed"} + + def test_the_caller_owns_the_end_user_and_the_route(self) -> None: + """Both describe the call the caller is asking about, not the key it authenticated with.""" + metadata: Final = _guardrail_request_metadata( + {"user_api_key_end_user_id": "customer-42", "user_api_key_request_route": "/v1/chat/completions"}, + {"user_api_key_request_route": "/apply_guardrail"}, + ) + assert metadata == { + "user_api_key_end_user_id": "customer-42", + "user_api_key_request_route": "/v1/chat/completions", + } + + def test_a_guardrail_can_annotate_what_it_is_handed(self) -> None: + """OpenAI moderation and Presidio write their detection details into this mapping.""" + metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) + metadata["moderation_model"] = "omni-moderation-latest" + assert metadata == {"user_api_key_alias": "billing-app", "moderation_model": "omni-moderation-latest"} + + @pytest.mark.asyncio async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_key(mocker): """Identity fields come from the proxy's own sanitized metadata, never from @@ -2291,42 +2331,6 @@ async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_k } -@pytest.mark.asyncio -async def test_apply_guardrail_hands_the_guardrail_metadata_it_can_write_to(mocker): - """Guardrails such as OpenAI moderation annotate the metadata they are given, so a - read-only mapping turns a normal evaluation into a 500.""" - mock_guardrail = _patch_apply_guardrail_env( - mocker, - {"texts": ["ok"]}, - processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, - ) - - annotated: Final[list[object]] = [] # mutable-ok: records the metadata the guardrail was handed - - async def annotating_guardrail(**kwargs): - kwargs["request_data"]["metadata"]["moderation_model"] = "omni-moderation-latest" - annotated.append(kwargs["request_data"]["metadata"]) - return {"texts": ["ok"]} - - mock_guardrail.apply_guardrail = annotating_guardrail - - await apply_guardrail( - fastapi_request=mocker.Mock(), - request=ApplyGuardrailRequest( - guardrail_name="test-guardrail", text="hello", metadata={"forbidden_topics": ["tax"]} - ), - user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), - ) - - assert annotated == [ - { - "forbidden_topics": ["tax"], - "user_api_key_alias": "billing-app", - "moderation_model": "omni-moderation-latest", - } - ] - - @pytest.mark.asyncio async def test_apply_guardrail_ignores_a_caller_supplied_user_api_key(mocker): """The bare key field carries attribution as much as the prefixed ones, so a caller From b1da2511cca2965c5f6ea00d37561d660cc5fae2 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Mon, 2 Nov 2026 00:07:50 +0100 Subject: [PATCH 06/13] fix(guardrails): reserve the identity fields that live outside the key namespace Attribution is not only the user_api_key_ namespace: pre-call processing also fills in the caller's ip address, user agent and agent id, and a caller could set those on /apply_guardrail and have a guardrail attribute the call to someone else. Reserve them by name alongside the namespace. --- .../proxy/guardrails/guardrail_endpoints.py | 19 +++++++++++----- .../guardrails/test_guardrail_endpoints.py | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 20dd66b92b4..2b3d10350d1 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2349,18 +2349,25 @@ def _metadata_fields(value: object) -> Mapping[str, object]: _CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id", "user_api_key_request_route"}) +_RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( + {"requester_ip_address", "user_agent", "agent_id", "actor_agent_id"} +) + def _is_key_identity_field(key: str) -> bool: - """Whether a metadata field attributes the authenticated key rather than the request. + """Whether a metadata field attributes the authenticated caller rather than the request. - The ``user_api_key_`` namespace also carries two fields that describe the caller's own - request: the end user it is made for, and the route it came from. A guardrail needs both - to decide, and on ``/apply_guardrail`` the proxy's own values say nothing about the call - the caller is asking about. + Pre-call processing owns the whole ``user_api_key_`` namespace, except the two fields that + describe the caller's own request: the end user it is made for, and the route it came from. + A guardrail needs both to decide, and on ``/apply_guardrail`` the proxy's own values say + nothing about the call the caller is asking about. + + Attribution also reaches outside that namespace, so the fields pre-call processing fills in + from the key and the connection are reserved by name as well. """ if key in _CALLER_OWNED_KEY_FIELDS: return False - return key == "user_api_key" or key.startswith("user_api_key_") + return key in _RESERVED_IDENTITY_FIELDS or key == "user_api_key" or key.startswith("user_api_key_") def _guardrail_request_metadata( diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index b5069cae4b3..836f68d5626 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2515,6 +2515,28 @@ class TestGuardrailRequestMetadata: "user_api_key_request_route": "/v1/chat/completions", } + def test_identity_fields_outside_the_key_namespace_are_reserved_too(self) -> None: + """The proxy sets who is calling and from where. A caller cannot claim another's.""" + metadata: Final = _guardrail_request_metadata( + { + "requester_ip_address": "203.0.113.9", + "user_agent": "forged/1.0", + "agent_id": "someone-elses-agent", + "topic": "tax", + }, + { + "requester_ip_address": "198.51.100.4", + "user_agent": "litellm-cli/2.3", + "agent_id": "billing-agent", + }, + ) + assert metadata == { + "topic": "tax", + "requester_ip_address": "198.51.100.4", + "user_agent": "litellm-cli/2.3", + "agent_id": "billing-agent", + } + def test_a_guardrail_can_annotate_what_it_is_handed(self) -> None: """OpenAI moderation and Presidio write their detection details into this mapping.""" metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) From b40e6ad4bfede4247bc1bb19520dba09b139968a Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Mon, 2 Nov 2026 00:15:53 +0100 Subject: [PATCH 07/13] test(guardrails): drop the empty request_data the pipeline never produces The case claimed a caller that sends no metadata leaves request_data empty, which only held because the harness mocked the pre-call step away. Pre-call processing always attaches the key's identity, so assert that instead. --- .../proxy/guardrails/test_guardrail_endpoints.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 836f68d5626..5b40b26416f 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2635,20 +2635,25 @@ async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metada @pytest.mark.asyncio -async def test_apply_guardrail_omits_metadata_when_not_sent(mocker): - """Without metadata, request_data stays empty (backward-compatible).""" - mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]}) +async def test_apply_guardrail_sends_the_key_identity_when_the_caller_sends_no_metadata(mocker): + """A caller that sends no metadata still arrives with the key's identity attached, because + pre-call processing fills it in. An empty request_data is not a shape the pipeline produces.""" + mock_guardrail = _patch_apply_guardrail_env( + mocker, + {"texts": ["ok"]}, + processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, + ) request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello") await apply_guardrail( fastapi_request=mocker.Mock(), request=request, - user_api_key_dict=UserAPIKeyAuth(), + user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), ) mock_guardrail.apply_guardrail.assert_awaited_once_with( inputs={"texts": ["hello"]}, - request_data={}, + request_data={"metadata": {"user_api_key_alias": "billing-app"}}, input_type="request", ) From 591002432dc7c6a462c6007f34fec6fab05ab074 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Tue, 3 Nov 2026 13:16:07 +0100 Subject: [PATCH 08/13] fix(guardrails): keep the request route out of a direct evaluation The tool policy guardrail reads the request itself when a route is present, instead of the tools replayed to it, so any route reaching it turns its checks off. The caller's was forwarded after the earlier fix, which handed that switch to the caller. Withhold the route from both sides: the endpoint's own describes the evaluation, not the call being evaluated. --- .../proxy/guardrails/guardrail_endpoints.py | 19 +++++++++++++------ .../guardrails/test_guardrail_endpoints.py | 17 ++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 2b3d10350d1..f41561a656f 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2347,7 +2347,9 @@ def _metadata_fields(value: object) -> Mapping[str, object]: return {} -_CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id", "user_api_key_request_route"}) +_CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id"}) + +_WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route"}) _RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( {"requester_ip_address", "user_agent", "agent_id", "actor_agent_id"} @@ -2357,10 +2359,8 @@ _RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( def _is_key_identity_field(key: str) -> bool: """Whether a metadata field attributes the authenticated caller rather than the request. - Pre-call processing owns the whole ``user_api_key_`` namespace, except the two fields that - describe the caller's own request: the end user it is made for, and the route it came from. - A guardrail needs both to decide, and on ``/apply_guardrail`` the proxy's own values say - nothing about the call the caller is asking about. + Pre-call processing owns the whole ``user_api_key_`` namespace, except the end user the call + is made for, which the caller owns here as it does on every LLM route. Attribution also reaches outside that namespace, so the fields pre-call processing fills in from the key and the connection are reserved by name as well. @@ -2377,12 +2377,19 @@ def _guardrail_request_metadata( The result is handed to the guardrail as ``request_data["metadata"]``, which providers write their own detection details into, so it cannot be a read-only mapping. + + The request route is withheld from both sides. The tool policy guardrail reads the request + itself when a route is present, instead of the tools replayed to it, so a route here turns + its checks off: the caller must not be able to set one, and this endpoint's own route + describes the evaluation rather than the call being evaluated. """ caller_fields: Final = tuple( (key, value) for key, value in _metadata_fields(caller).items() if not _is_key_identity_field(key) ) key_identity: Final = tuple( - (key, value) for key, value in _metadata_fields(proxy).items() if _is_key_identity_field(key) + (key, value) + for key, value in _metadata_fields(proxy).items() + if _is_key_identity_field(key) and key not in _WITHHELD_FIELDS ) return dict((*caller_fields, *key_identity)) diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 5b40b26416f..8dde8c1b034 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2504,16 +2504,19 @@ class TestGuardrailRequestMetadata: metadata: Final = _guardrail_request_metadata({"user_api_key": "spoof"}, {"user_api_key": "hashed"}) assert metadata == {"user_api_key": "hashed"} - def test_the_caller_owns_the_end_user_and_the_route(self) -> None: - """Both describe the call the caller is asking about, not the key it authenticated with.""" + def test_the_caller_owns_the_end_user(self) -> None: + """The end user describes the call the caller is asking about, as it does on every LLM route.""" + metadata: Final = _guardrail_request_metadata({"user_api_key_end_user_id": "customer-42"}, {}) + assert metadata == {"user_api_key_end_user_id": "customer-42"} + + def test_no_request_route_reaches_the_guardrail(self) -> None: + """A route makes the tool policy guardrail read the request instead of the replayed tools, + so neither the caller's nor the endpoint's own route may reach it.""" metadata: Final = _guardrail_request_metadata( - {"user_api_key_end_user_id": "customer-42", "user_api_key_request_route": "/v1/chat/completions"}, + {"user_api_key_request_route": "/v1/chat/completions", "topic": "tax"}, {"user_api_key_request_route": "/apply_guardrail"}, ) - assert metadata == { - "user_api_key_end_user_id": "customer-42", - "user_api_key_request_route": "/v1/chat/completions", - } + assert metadata == {"topic": "tax"} def test_identity_fields_outside_the_key_namespace_are_reserved_too(self) -> None: """The proxy sets who is calling and from where. A caller cannot claim another's.""" From a32628fd7e85fe40cb04be606d1304e30295685a Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Tue, 3 Nov 2026 13:25:59 +0100 Subject: [PATCH 09/13] test(guardrails): make the metadata cases standalone and typed The cases shared no setup, so they do not belong in a class, the mocker parameters carried no type, and most docstrings repeated the test name. Keep only the two that say something the code does not. --- .../guardrails/test_guardrail_endpoints.py | 99 +++++++++---------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 8dde8c1b034..8c6c7f183f3 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2486,65 +2486,64 @@ async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker): ) -class TestGuardrailRequestMetadata: - """What a guardrail is told about the caller on /apply_guardrail.""" +def test_the_authenticated_key_identity_replaces_the_caller_version() -> None: + metadata: Final = _guardrail_request_metadata( + {"forbidden_topics": ["tax"], "user_api_key_alias": "someone-else"}, + {"user_api_key_alias": "billing-app", "user_api_key_user_id": "u-1"}, + ) + assert metadata == { + "forbidden_topics": ["tax"], + "user_api_key_alias": "billing-app", + "user_api_key_user_id": "u-1", + } - def test_the_authenticated_key_identity_replaces_the_caller_version(self) -> None: - metadata: Final = _guardrail_request_metadata( - {"forbidden_topics": ["tax"], "user_api_key_alias": "someone-else"}, - {"user_api_key_alias": "billing-app", "user_api_key_user_id": "u-1"}, - ) - assert metadata == { - "forbidden_topics": ["tax"], - "user_api_key_alias": "billing-app", - "user_api_key_user_id": "u-1", - } - def test_the_bare_key_hash_cannot_come_from_the_caller(self) -> None: - metadata: Final = _guardrail_request_metadata({"user_api_key": "spoof"}, {"user_api_key": "hashed"}) - assert metadata == {"user_api_key": "hashed"} +def test_the_bare_key_hash_cannot_come_from_the_caller() -> None: + metadata: Final = _guardrail_request_metadata({"user_api_key": "spoof"}, {"user_api_key": "hashed"}) + assert metadata == {"user_api_key": "hashed"} - def test_the_caller_owns_the_end_user(self) -> None: - """The end user describes the call the caller is asking about, as it does on every LLM route.""" - metadata: Final = _guardrail_request_metadata({"user_api_key_end_user_id": "customer-42"}, {}) - assert metadata == {"user_api_key_end_user_id": "customer-42"} - def test_no_request_route_reaches_the_guardrail(self) -> None: - """A route makes the tool policy guardrail read the request instead of the replayed tools, - so neither the caller's nor the endpoint's own route may reach it.""" - metadata: Final = _guardrail_request_metadata( - {"user_api_key_request_route": "/v1/chat/completions", "topic": "tax"}, - {"user_api_key_request_route": "/apply_guardrail"}, - ) - assert metadata == {"topic": "tax"} +def test_the_caller_owns_the_end_user() -> None: + metadata: Final = _guardrail_request_metadata({"user_api_key_end_user_id": "customer-42"}, {}) + assert metadata == {"user_api_key_end_user_id": "customer-42"} - def test_identity_fields_outside_the_key_namespace_are_reserved_too(self) -> None: - """The proxy sets who is calling and from where. A caller cannot claim another's.""" - metadata: Final = _guardrail_request_metadata( - { - "requester_ip_address": "203.0.113.9", - "user_agent": "forged/1.0", - "agent_id": "someone-elses-agent", - "topic": "tax", - }, - { - "requester_ip_address": "198.51.100.4", - "user_agent": "litellm-cli/2.3", - "agent_id": "billing-agent", - }, - ) - assert metadata == { + +def test_no_request_route_reaches_the_guardrail() -> None: + """A route makes the tool policy guardrail read the request instead of the replayed tools.""" + metadata: Final = _guardrail_request_metadata( + {"user_api_key_request_route": "/v1/chat/completions", "topic": "tax"}, + {"user_api_key_request_route": "/apply_guardrail"}, + ) + assert metadata == {"topic": "tax"} + + +def test_identity_fields_outside_the_key_namespace_are_reserved_too() -> None: + metadata: Final = _guardrail_request_metadata( + { + "requester_ip_address": "203.0.113.9", + "user_agent": "forged/1.0", + "agent_id": "someone-elses-agent", "topic": "tax", + }, + { "requester_ip_address": "198.51.100.4", "user_agent": "litellm-cli/2.3", "agent_id": "billing-agent", - } + }, + ) + assert metadata == { + "topic": "tax", + "requester_ip_address": "198.51.100.4", + "user_agent": "litellm-cli/2.3", + "agent_id": "billing-agent", + } - def test_a_guardrail_can_annotate_what_it_is_handed(self) -> None: - """OpenAI moderation and Presidio write their detection details into this mapping.""" - metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) - metadata["moderation_model"] = "omni-moderation-latest" - assert metadata == {"user_api_key_alias": "billing-app", "moderation_model": "omni-moderation-latest"} + +def test_a_guardrail_can_annotate_what_it_is_handed() -> None: + """OpenAI moderation and Presidio write their detection details into this mapping.""" + metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) + metadata["moderation_model"] = "omni-moderation-latest" + assert metadata == {"user_api_key_alias": "billing-app", "moderation_model": "omni-moderation-latest"} @pytest.mark.asyncio @@ -2638,7 +2637,7 @@ async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metada @pytest.mark.asyncio -async def test_apply_guardrail_sends_the_key_identity_when_the_caller_sends_no_metadata(mocker): +async def test_apply_guardrail_sends_the_key_identity_when_the_caller_sends_no_metadata(mocker: MockerFixture) -> None: """A caller that sends no metadata still arrives with the key's identity attached, because pre-call processing fills it in. An empty request_data is not a shape the pipeline produces.""" mock_guardrail = _patch_apply_guardrail_env( From 128ced5aab4365bfff2193e937518f7104fa6fd9 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Tue, 3 Nov 2026 13:53:19 +0100 Subject: [PATCH 10/13] fix(guardrails): take the agent from the authenticated key, not the request Pre-call processing keeps the request's own agent_id when the key is bound to none, so the processed metadata already carried a caller-chosen agent and reserving the field did not stop it reaching a vendor as the application source. Read it from the auth object, and omit it when the key is bound to no agent. --- .../proxy/guardrails/guardrail_endpoints.py | 15 ++++++++---- .../guardrails/test_guardrail_endpoints.py | 23 +++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index f41561a656f..f14e24d3383 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2349,7 +2349,7 @@ def _metadata_fields(value: object) -> Mapping[str, object]: _CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id"}) -_WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route"}) +_WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route", "agent_id"}) _RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( {"requester_ip_address", "user_agent", "agent_id", "actor_agent_id"} @@ -2371,7 +2371,7 @@ def _is_key_identity_field(key: str) -> bool: def _guardrail_request_metadata( - caller: object, proxy: object + caller: object, proxy: object, agent_id: str | None = None ) -> dict[str, object]: # mutable-ok: guardrails annotate the metadata they are handed """Caller metadata with the identity fields taken from the authenticated key. @@ -2382,6 +2382,10 @@ def _guardrail_request_metadata( itself when a route is present, instead of the tools replayed to it, so a route here turns its checks off: the caller must not be able to set one, and this endpoint's own route describes the evaluation rather than the call being evaluated. + + The agent is taken from ``agent_id``, the one the key authenticated with, and omitted when the + key is bound to none. Pre-call processing keeps the request's own agent when the key has none, + so the processed metadata is not a trustworthy source for it either. """ caller_fields: Final = tuple( (key, value) for key, value in _metadata_fields(caller).items() if not _is_key_identity_field(key) @@ -2391,7 +2395,8 @@ def _guardrail_request_metadata( for key, value in _metadata_fields(proxy).items() if _is_key_identity_field(key) and key not in _WITHHELD_FIELDS ) - return dict((*caller_fields, *key_identity)) + bound_agent: Final = (("agent_id", agent_id),) if agent_id is not None else () + return dict((*caller_fields, *key_identity, *bound_agent)) def _execution_timeout_response(timeout: float) -> TestCustomCodeGuardrailResponse: @@ -2562,7 +2567,9 @@ async def apply_guardrail( request_data: Final = dict(response_handler.prepare_request_data(request)) caller_metadata: Final = request_data.get("metadata") metadata: Final = _guardrail_request_metadata( - caller_metadata, _GUARDRAIL_METADATA_ADAPTER.validate_python(data).get("metadata") + caller_metadata, + _GUARDRAIL_METADATA_ADAPTER.validate_python(data).get("metadata"), + agent_id=user_api_key_dict.invoked_agent_id or user_api_key_dict.agent_id, ) if caller_metadata is not None or metadata: request_data["metadata"] = metadata diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 8c6c7f183f3..4c09f9afb4e 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2517,6 +2517,22 @@ def test_no_request_route_reaches_the_guardrail() -> None: assert metadata == {"topic": "tax"} +def test_an_unbound_agent_is_not_taken_from_the_request() -> None: + """Pre-call processing keeps the request's own agent_id when the key has none, so the proxy's + metadata already carries a caller-chosen value and reserving the field is not enough.""" + metadata: Final = _guardrail_request_metadata( + {"agent_id": "someone-elses-agent"}, {"agent_id": "someone-elses-agent"} + ) + assert metadata == {} + + +def test_a_bound_agent_comes_from_the_authenticated_key() -> None: + metadata: Final = _guardrail_request_metadata( + {"agent_id": "someone-elses-agent"}, {"agent_id": "someone-elses-agent"}, agent_id="billing-agent" + ) + assert metadata == {"agent_id": "billing-agent"} + + def test_identity_fields_outside_the_key_namespace_are_reserved_too() -> None: metadata: Final = _guardrail_request_metadata( { @@ -2525,11 +2541,8 @@ def test_identity_fields_outside_the_key_namespace_are_reserved_too() -> None: "agent_id": "someone-elses-agent", "topic": "tax", }, - { - "requester_ip_address": "198.51.100.4", - "user_agent": "litellm-cli/2.3", - "agent_id": "billing-agent", - }, + {"requester_ip_address": "198.51.100.4", "user_agent": "litellm-cli/2.3"}, + agent_id="billing-agent", ) assert metadata == { "topic": "tax", From bf125c77c73f7e4415e7801214d97e5274d9c0ef Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Thu, 5 Nov 2026 11:32:23 +0100 Subject: [PATCH 11/13] fix(guardrails)!: take the end user and the agent fields from the authenticated key The endpoint classified identity with its own list, which let a caller forge the end user and left the agent, header and connection fields pre-call processing fills open to a body value. Reuse the sanitizer the LLM routes use, extracted into a shared function, and reserve the fields pre-call fills right after it. The endpoint tests now register a real guardrail and drive the route over HTTP instead of doubling the pre-call path and the registry. --- .../proxy/guardrails/guardrail_endpoints.py | 44 ++-- litellm/proxy/litellm_pre_call_utils.py | 33 ++- .../guardrails/test_guardrail_endpoints.py | 224 ++++++++++-------- 3 files changed, 174 insertions(+), 127 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index f14e24d3383..89ad4d3c59e 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2347,27 +2347,31 @@ def _metadata_fields(value: object) -> Mapping[str, object]: return {} -_CALLER_OWNED_KEY_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_end_user_id"}) - _WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route", "agent_id"}) -_RESERVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( - {"requester_ip_address", "user_agent", "agent_id", "actor_agent_id"} +_PROXY_RESOLVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( + { + "requester_ip_address", + "user_agent", + "headers", + "agent_id", + "actor_agent_id", + "target_agent_id", + "billing_agent_id", + "agent_execution_mode", + "verified_human_user_id", + } ) -def _is_key_identity_field(key: str) -> bool: +def _is_proxy_identity_field(key: str) -> bool: """Whether a metadata field attributes the authenticated caller rather than the request. - Pre-call processing owns the whole ``user_api_key_`` namespace, except the end user the call - is made for, which the caller owns here as it does on every LLM route. - - Attribution also reaches outside that namespace, so the fields pre-call processing fills in - from the key and the connection are reserved by name as well. + Pre-call processing owns the whole ``user_api_key_`` namespace, the end user included, and + fills the agent, connection and header fields from the key and the connection right after, + so a caller value never reaches an LLM route. The same fields are reserved here. """ - if key in _CALLER_OWNED_KEY_FIELDS: - return False - return key in _RESERVED_IDENTITY_FIELDS or key == "user_api_key" or key.startswith("user_api_key_") + return key in _PROXY_RESOLVED_IDENTITY_FIELDS or key == "user_api_key" or key.startswith("user_api_key_") def _guardrail_request_metadata( @@ -2378,6 +2382,10 @@ def _guardrail_request_metadata( The result is handed to the guardrail as ``request_data["metadata"]``, which providers write their own detection details into, so it cannot be a read-only mapping. + The caller's bucket goes through the sanitizer the LLM routes use, so this endpoint drops + the same fields they do, and what that leaves is then overlaid with the identity the proxy + resolved. + The request route is withheld from both sides. The tool policy guardrail reads the request itself when a route is present, instead of the tools replayed to it, so a route here turns its checks off: the caller must not be able to set one, and this endpoint's own route @@ -2387,13 +2395,15 @@ def _guardrail_request_metadata( key is bound to none. Pre-call processing keeps the request's own agent when the key has none, so the processed metadata is not a trustworthy source for it either. """ - caller_fields: Final = tuple( - (key, value) for key, value in _metadata_fields(caller).items() if not _is_key_identity_field(key) - ) + from litellm.proxy.litellm_pre_call_utils import strip_caller_supplied_identity + + sanitized: Final = dict(_metadata_fields(caller)) + strip_caller_supplied_identity(sanitized) + caller_fields: Final = tuple((key, value) for key, value in sanitized.items() if not _is_proxy_identity_field(key)) key_identity: Final = tuple( (key, value) for key, value in _metadata_fields(proxy).items() - if _is_key_identity_field(key) and key not in _WITHHELD_FIELDS + if _is_proxy_identity_field(key) and key not in _WITHHELD_FIELDS ) bound_agent: Final = (("agent_id", agent_id),) if agent_id is not None else () return dict((*caller_fields, *key_identity, *bound_agent)) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 4444d6d25f9..bee21b9e549 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -858,6 +858,29 @@ def strip_client_guardrail_information(data: dict[str, object]) -> None: # muta ) +def strip_caller_supplied_identity( + metadata: dict[str, object], # mutable-ok: strips in place on the metadata bucket it is handed + *, + allow_client_message_redaction_opt_out: bool = False, +) -> None: + """Drop from a caller-supplied metadata bucket the fields pre-call processing owns. + + The proxy fills a family of ``user_api_key_*`` fields into one of the two metadata dicts, + so a caller pre-populating any of them on the other dict would have its forged values + surface in guardrails, spend tracking, audit logs and identity resolution. Stripping by + prefix covers ``user_api_key_*`` fields added later without per-key maintenance. + + Shared with ``/apply_guardrail``, which accepts caller metadata the same way and so has to + take identity from the authenticated key rather than from the body. + """ + _strip_untrusted_request_header_controls( + metadata.get("headers"), + allow_client_message_redaction_opt_out=allow_client_message_redaction_opt_out, + ) + for key in [k for k in metadata if k.startswith("user_api_key_") or k in _UNTRUSTED_METADATA_CONTROL_FIELDS]: + metadata.pop(key, None) + + def _strip_router_reserved_metadata( data: dict[str, object], # mutable-ok: strips in place on the request body the pre-call pipeline threads through ) -> None: @@ -2835,14 +2858,10 @@ async def add_litellm_data_to_request( for _meta_key in ("metadata", "litellm_metadata"): _user_meta = data.get(_meta_key) if isinstance(_user_meta, dict): - _strip_untrusted_request_header_controls( - _user_meta.get("headers"), - allow_client_message_redaction_opt_out=(_allow_client_message_redaction_opt_out), + strip_caller_supplied_identity( + _user_meta, + allow_client_message_redaction_opt_out=_allow_client_message_redaction_opt_out, ) - for _k in [ - k for k in _user_meta if k.startswith("user_api_key_") or k in _UNTRUSTED_METADATA_CONTROL_FIELDS - ]: - _user_meta.pop(_k, None) # Strip pricing overrides AFTER the litellm_metadata string-to-dict parse # above, for the same reason as the user_api_key_* strip — JSON-string diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 4c09f9afb4e..87588caa018 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2503,11 +2503,20 @@ def test_the_bare_key_hash_cannot_come_from_the_caller() -> None: assert metadata == {"user_api_key": "hashed"} -def test_the_caller_owns_the_end_user() -> None: - metadata: Final = _guardrail_request_metadata({"user_api_key_end_user_id": "customer-42"}, {}) +def test_the_end_user_comes_from_the_proxy_not_the_caller() -> None: + """LLM routes resolve the end user and drop whatever the body said, so this endpoint does too.""" + metadata: Final = _guardrail_request_metadata( + {"user_api_key_end_user_id": "someone-elses-customer"}, + {"user_api_key_end_user_id": "customer-42"}, + ) assert metadata == {"user_api_key_end_user_id": "customer-42"} +def test_an_end_user_the_proxy_did_not_resolve_is_dropped() -> None: + metadata: Final = _guardrail_request_metadata({"user_api_key_end_user_id": "someone-elses-customer"}, {}) + assert metadata == {} + + def test_no_request_route_reaches_the_guardrail() -> None: """A route makes the tool policy guardrail read the request instead of the replayed tools.""" metadata: Final = _guardrail_request_metadata( @@ -2534,24 +2543,60 @@ def test_a_bound_agent_comes_from_the_authenticated_key() -> None: def test_identity_fields_outside_the_key_namespace_are_reserved_too() -> None: + """Pre-call processing fills every one of these from the key and the connection, so an LLM + route never carries a caller value for them and neither may this endpoint.""" metadata: Final = _guardrail_request_metadata( { "requester_ip_address": "203.0.113.9", "user_agent": "forged/1.0", "agent_id": "someone-elses-agent", + "actor_agent_id": "someone-elses-actor", + "target_agent_id": "someone-elses-target", + "billing_agent_id": "someone-elses-payer", + "agent_execution_mode": "forged", + "verified_human_user_id": "someone-else", "topic": "tax", }, - {"requester_ip_address": "198.51.100.4", "user_agent": "litellm-cli/2.3"}, + { + "requester_ip_address": "198.51.100.4", + "user_agent": "litellm-cli/2.3", + "actor_agent_id": "support-agent", + "target_agent_id": "billing-agent", + "billing_agent_id": "support-agent", + "agent_execution_mode": "managed", + "verified_human_user_id": "user-7", + }, agent_id="billing-agent", ) assert metadata == { "topic": "tax", "requester_ip_address": "198.51.100.4", "user_agent": "litellm-cli/2.3", + "actor_agent_id": "support-agent", + "target_agent_id": "billing-agent", + "billing_agent_id": "support-agent", + "agent_execution_mode": "managed", + "verified_human_user_id": "user-7", "agent_id": "billing-agent", } +def test_a_reserved_identity_field_the_proxy_left_empty_is_dropped() -> None: + """Reserving the names is what keeps a forged value out when the proxy resolved none.""" + metadata: Final = _guardrail_request_metadata( + { + "actor_agent_id": "someone-elses-actor", + "target_agent_id": "someone-elses-target", + "billing_agent_id": "someone-elses-payer", + "agent_execution_mode": "forged", + "verified_human_user_id": "someone-else", + "topic": "tax", + }, + {}, + ) + assert metadata == {"topic": "tax"} + + def test_a_guardrail_can_annotate_what_it_is_handed() -> None: """OpenAI moderation and Presidio write their detection details into this mapping.""" metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) @@ -2559,118 +2604,91 @@ def test_a_guardrail_can_annotate_what_it_is_handed() -> None: assert metadata == {"user_api_key_alias": "billing-app", "moderation_model": "omni-moderation-latest"} -@pytest.mark.asyncio -async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_key(mocker): - """Identity fields come from the proxy's own sanitized metadata, never from - the caller, so a request cannot impersonate another key or probe its policy.""" - mock_guardrail = _patch_apply_guardrail_env( - mocker, - {"texts": ["ok"]}, - processed_data={ - "guardrail_name": "test-guardrail", - "metadata": { - "route": "/apply_guardrail", - "user_api_key_alias": "billing-app", - "user_api_key_user_id": "u-1", - }, - }, - ) +class _MetadataRecordingGuardrail(CustomGuardrail): + """A guardrail registered the way the proxy registers one, keeping what the endpoint hands it.""" - request = ApplyGuardrailRequest( - guardrail_name="test-guardrail", - text="hello", - metadata={ + def __init__(self) -> None: + super().__init__(guardrail_name="identity-probe") + self.calls: Final[list[dict[str, object]]] = [] + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional[object] = None, + ) -> GenericGuardrailAPIInputs: + self.calls.append(dict(request_data)) + return inputs + + +async def _metadata_the_guardrail_was_handed( + caller_metadata: dict[str, object] | None, auth: UserAPIKeyAuth +) -> dict[str, object]: + """Drive the real endpoint with a real registered guardrail and report what reached it. + + A registered guardrail also runs as an ordinary callback on this request, so the endpoint's + own call is the first one it receives.""" + import litellm + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.guardrails.guardrail_endpoints import router + from litellm.proxy.proxy_server import openai_exception_handler + + guardrail: Final = _MetadataRecordingGuardrail() + litellm.logging_callback_manager.add_litellm_callback(guardrail) + body: Final[dict[str, object]] = {"guardrail_name": "identity-probe", "text": "hello"} + if caller_metadata is not None: + body["metadata"] = caller_metadata + app: Final = FastAPI() + app.include_router(router) + app.add_exception_handler(ProxyException, openai_exception_handler) + app.dependency_overrides[user_api_key_auth] = lambda: auth + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://guardrails.test" + ) as client: + response: Final = await client.post("/guardrails/apply_guardrail", json=body) + assert response.status_code == 200, response.text + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) + assert guardrail.calls, "the endpoint never reached the registered guardrail" + return dict(guardrail.calls[0].get("metadata") or {}) + + +@pytest.mark.asyncio +async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_key() -> None: + """Identity fields come from the proxy's own metadata, never from the caller, so a request + cannot impersonate another key or probe its policy.""" + forwarded: Final = await _metadata_the_guardrail_was_handed( + { "forbidden_topics": ["tax"], "user_api_key_alias": "someone-else", "user_api_key_user_email": "victim@example.com", + "user_api_key": "hash-of-someone-else", }, + UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), ) - await apply_guardrail( - fastapi_request=mocker.Mock(), - request=request, - user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), - ) - - forwarded = mock_guardrail.apply_guardrail.await_args.kwargs["request_data"]["metadata"] - assert forwarded == { - "forbidden_topics": ["tax"], - "user_api_key_alias": "billing-app", - "user_api_key_user_id": "u-1", - } + assert forwarded["forbidden_topics"] == ["tax"] + assert forwarded.get("user_api_key_alias") == "billing-app" + assert "victim@example.com" not in forwarded.values() + assert forwarded.get("user_api_key") != "hash-of-someone-else" @pytest.mark.asyncio -async def test_apply_guardrail_ignores_a_caller_supplied_user_api_key(mocker): - """The bare key field carries attribution as much as the prefixed ones, so a caller - cannot set it either.""" - mock_guardrail = _patch_apply_guardrail_env( - mocker, - {"texts": ["ok"]}, - processed_data={ - "guardrail_name": "test-guardrail", - "metadata": {"route": "/apply_guardrail", "user_api_key": "hash-of-the-real-key"}, - }, - ) - - request = ApplyGuardrailRequest( - guardrail_name="test-guardrail", - text="hello", - metadata={"forbidden_topics": ["tax"], "user_api_key": "hash-of-someone-else"}, - ) - await apply_guardrail( - fastapi_request=mocker.Mock(), - request=request, - user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), - ) - - forwarded = mock_guardrail.apply_guardrail.await_args.kwargs["request_data"]["metadata"] - assert forwarded == {"forbidden_topics": ["tax"], "user_api_key": "hash-of-the-real-key"} - - -@pytest.mark.asyncio -async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metadata(mocker): - """A caller that sends no metadata still gets the authenticated identity - forwarded, the same shape every LLM route gives a guardrail.""" - mock_guardrail = _patch_apply_guardrail_env( - mocker, - {"texts": ["ok"]}, - processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, - ) - - request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello") - await apply_guardrail( - fastapi_request=mocker.Mock(), - request=request, - user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), - ) - - assert mock_guardrail.apply_guardrail.await_args.kwargs["request_data"] == { - "metadata": {"user_api_key_alias": "billing-app"} - } - - -@pytest.mark.asyncio -async def test_apply_guardrail_sends_the_key_identity_when_the_caller_sends_no_metadata(mocker: MockerFixture) -> None: +async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metadata() -> None: """A caller that sends no metadata still arrives with the key's identity attached, because - pre-call processing fills it in. An empty request_data is not a shape the pipeline produces.""" - mock_guardrail = _patch_apply_guardrail_env( - mocker, - {"texts": ["ok"]}, - processed_data={"guardrail_name": "test-guardrail", "metadata": {"user_api_key_alias": "billing-app"}}, - ) + pre-call processing fills it in.""" + forwarded: Final = await _metadata_the_guardrail_was_handed(None, UserAPIKeyAuth(key_alias="billing-app")) + assert forwarded.get("user_api_key_alias") == "billing-app" - request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello") - await apply_guardrail( - fastapi_request=mocker.Mock(), - request=request, - user_api_key_dict=UserAPIKeyAuth(key_alias="billing-app"), - ) - mock_guardrail.apply_guardrail.assert_awaited_once_with( - inputs={"texts": ["hello"]}, - request_data={"metadata": {"user_api_key_alias": "billing-app"}}, - input_type="request", - ) +@pytest.mark.asyncio +async def test_apply_guardrail_prefers_the_agent_the_key_was_invoked_as() -> None: + """A key bound to an agent can be invoked as another, and the invoked one is who is acting.""" + auth: Final = UserAPIKeyAuth(agent_id="support-agent") + auth.invoked_agent_id = "billing-agent" + forwarded: Final = await _metadata_the_guardrail_was_handed(None, auth) + assert forwarded.get("agent_id") == "billing-agent" @pytest.mark.asyncio From 48bcd9178d4d4a41387f493e0d0ec235c2173364 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Fri, 6 Nov 2026 11:18:53 +0100 Subject: [PATCH 12/13] fix(guardrails): forward only what a vendor can encode, without the credential headers Copying the whole key namespace brought the authenticated key object with it, which no vendor can encode, and the header bucket went as it stood, with anthropic-api-key and x-amz-security-token in clear. Forward only values that survive JSON encoding, drop the headers carrying a credential, and reserve the end user budget a caller could still set. --- .../proxy/guardrails/guardrail_endpoints.py | 40 ++++++++++++--- .../test_guardrail_submissions.py | 6 ++- .../test_apply_guardrail_endpoint.py | 8 ++- .../test_apply_guardrail_endpoint.py | 4 +- .../guardrails/test_guardrail_endpoints.py | 49 +++++++++++++++++-- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 89ad4d3c59e..268cb26168e 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2347,7 +2347,7 @@ def _metadata_fields(value: object) -> Mapping[str, object]: return {} -_WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route", "agent_id"}) +_WITHHELD_FIELDS: Final[frozenset[str]] = frozenset({"user_api_key_request_route", "agent_id", "user_api_key_auth"}) _PROXY_RESOLVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( { @@ -2360,9 +2360,34 @@ _PROXY_RESOLVED_IDENTITY_FIELDS: Final[frozenset[str]] = frozenset( "billing_agent_id", "agent_execution_mode", "verified_human_user_id", + "user_api_end_user_max_budget", } ) +_SECRET_HEADER_MARKERS: Final = ("authorization", "cookie", "key", "token", "secret", "password", "credential") + + +def _is_secret_header(name: str) -> bool: + """Whether a header name carries a credential rather than request context.""" + lowered: Final = name.lower() + return any(marker in lowered for marker in _SECRET_HEADER_MARKERS) + + +def _is_json_safe(value: object) -> bool: + """Whether a value survives the JSON encoding a guardrail vendor puts it through.""" + try: + json.dumps(value) + except (TypeError, ValueError): + return False + return True + + +def _forwarded_identity_value(key: str, value: object) -> object: + """The value to forward, with the credential headers taken out of the header bucket.""" + if key != "headers": + return value + return {name: item for name, item in _metadata_fields(value).items() if not _is_secret_header(name)} + def _is_proxy_identity_field(key: str) -> bool: """Whether a metadata field attributes the authenticated caller rather than the request. @@ -2384,7 +2409,9 @@ def _guardrail_request_metadata( The caller's bucket goes through the sanitizer the LLM routes use, so this endpoint drops the same fields they do, and what that leaves is then overlaid with the identity the proxy - resolved. + resolved. A vendor receives this encoded as JSON, so only JSON-safe values are forwarded, + which leaves the authenticated key object behind, and the headers go without the ones + carrying a credential. The request route is withheld from both sides. The tool policy guardrail reads the request itself when a route is present, instead of the tools replayed to it, so a route here turns @@ -2401,9 +2428,9 @@ def _guardrail_request_metadata( strip_caller_supplied_identity(sanitized) caller_fields: Final = tuple((key, value) for key, value in sanitized.items() if not _is_proxy_identity_field(key)) key_identity: Final = tuple( - (key, value) + (key, _forwarded_identity_value(key, value)) for key, value in _metadata_fields(proxy).items() - if _is_proxy_identity_field(key) and key not in _WITHHELD_FIELDS + if _is_proxy_identity_field(key) and key not in _WITHHELD_FIELDS and _is_json_safe(value) ) bound_agent: Final = (("agent_id", agent_id),) if agent_id is not None else () return dict((*caller_fields, *key_identity, *bound_agent)) @@ -2581,10 +2608,7 @@ async def apply_guardrail( _GUARDRAIL_METADATA_ADAPTER.validate_python(data).get("metadata"), agent_id=user_api_key_dict.invoked_agent_id or user_api_key_dict.agent_id, ) - if caller_metadata is not None or metadata: - request_data["metadata"] = metadata - else: - request_data.pop("metadata", None) + request_data["metadata"] = metadata input_type: Final = response_handler.resolve_input_type(request, active_guardrail) guardrailed_inputs: Final = await active_guardrail.apply_guardrail( inputs={"texts": [request.text]}, diff --git a/tests/integration/authorization/test_guardrail_submissions.py b/tests/integration/authorization/test_guardrail_submissions.py index fb095010eb2..bbb891d6bed 100644 --- a/tests/integration/authorization/test_guardrail_submissions.py +++ b/tests/integration/authorization/test_guardrail_submissions.py @@ -233,7 +233,11 @@ def _assert_guardrail_request( assert payload["litellm_version"] is None or isinstance(payload["litellm_version"], str), payload request_data: Final = object_value(payload["request_data"]) if model is None: - assert request_data == {}, payload + assert set(request_data) == {"metadata"}, payload + direct_metadata: Final = object_value(request_data["metadata"]) + assert direct_metadata.get("user_api_key_alias") == key_alias, direct_metadata + assert "user_api_key_auth" not in direct_metadata, direct_metadata + assert "authorization" not in object_value(direct_metadata.get("headers") or {}), direct_metadata else: assert set(request_data).issubset( { diff --git a/tests/integration/observability/test_apply_guardrail_endpoint.py b/tests/integration/observability/test_apply_guardrail_endpoint.py index f50679bd54e..402a2224013 100644 --- a/tests/integration/observability/test_apply_guardrail_endpoint.py +++ b/tests/integration/observability/test_apply_guardrail_endpoint.py @@ -116,7 +116,13 @@ def _assert_guardrail_request(request: Request, text: str, response: httpx.Respo "tool_calls": None, "model": None, }, response.text - assert payload["request_data"] == {}, response.text + direct_request_data: Final = payload["request_data"] + assert isinstance(direct_request_data, dict), response.text + assert set(direct_request_data) == {"metadata"}, response.text + direct_metadata: Final = direct_request_data["metadata"] + assert isinstance(direct_metadata, dict), response.text + assert "user_api_key_auth" not in direct_metadata, response.text + assert "authorization" not in (direct_metadata.get("headers") or {}), response.text request_headers: Final = payload["request_headers"] assert request_headers is None or isinstance(request_headers, dict), response.text if isinstance(request_headers, dict): diff --git a/tests/unit/enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py b/tests/unit/enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py index 2921011d6eb..828062c1c54 100644 --- a/tests/unit/enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py +++ b/tests/unit/enterprise/proxy/guardrails/test_apply_guardrail_endpoint.py @@ -63,7 +63,7 @@ async def test_apply_guardrail_endpoint_returns_correct_response( # Verify the guardrail was called with correct parameters mock_guardrail.apply_guardrail.assert_called_once_with( inputs={"texts": ["Test text with PII"]}, - request_data={}, + request_data={"metadata": {}}, input_type="request", ) @@ -198,5 +198,5 @@ async def test_apply_guardrail_endpoint_without_optional_params(mock_proxy_loggi # Verify the guardrail was called with correct parameters mock_guardrail.apply_guardrail.assert_called_once_with( - inputs={"texts": ["Test text"]}, request_data={}, input_type="request" + inputs={"texts": ["Test text"]}, request_data={"metadata": {}}, input_type="request" ) diff --git a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py index 87588caa018..827d95bac23 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/unit/proxy/guardrails/test_guardrail_endpoints.py @@ -2171,7 +2171,7 @@ async def test_apply_guardrail_invokes_logging_pipeline(mocker): def _patch_apply_guardrail_env( - mocker: MockerFixture, guardrail_result: object, processed_data: dict[str, object] | None = None + mocker: MockerFixture, guardrail_result: object ) -> Mock: mock_guardrail: Final = mocker.Mock() mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result) @@ -2185,7 +2185,7 @@ def _patch_apply_guardrail_env( mock_logging_obj.model_call_details = {} mock_processor: Final = mocker.Mock() mock_processor.common_processing_pre_call_logic = AsyncMock( - return_value=(processed_data or {"guardrail_name": "test-guardrail"}, mock_logging_obj) + return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj) ) mocker.patch( "litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing", @@ -2597,6 +2597,43 @@ def test_a_reserved_identity_field_the_proxy_left_empty_is_dropped() -> None: assert metadata == {"topic": "tax"} +def test_the_end_user_budget_is_not_a_caller_field() -> None: + metadata: Final = _guardrail_request_metadata( + {"user_api_end_user_max_budget": 999.0, "topic": "tax"}, + {"user_api_end_user_max_budget": 10.0}, + ) + assert metadata == {"topic": "tax", "user_api_end_user_max_budget": 10.0} + + +def test_the_authenticated_key_object_is_left_behind() -> None: + """A guardrail vendor receives this encoded as JSON, which the auth object does not survive.""" + metadata: Final = _guardrail_request_metadata( + {}, + { + "user_api_key_auth": UserAPIKeyAuth(key_alias="billing-app"), + "user_api_key_metadata": {"tags": {"a", "b"}}, + "user_api_key_alias": "billing-app", + }, + ) + assert metadata == {"user_api_key_alias": "billing-app"} + + +def test_credential_headers_do_not_reach_the_guardrail() -> None: + """The header bucket carries provider credentials that the logging redaction does not name.""" + metadata: Final = _guardrail_request_metadata( + {}, + { + "headers": { + "anthropic-api-key": "sk-ant-secret", + "x-amz-security-token": "aws-session-token", + "authorization": "Bearer sk-caller", + "user-agent": "curl/8.7", + } + }, + ) + assert metadata == {"headers": {"user-agent": "curl/8.7"}} + + def test_a_guardrail_can_annotate_what_it_is_handed() -> None: """OpenAI moderation and Presidio write their detection details into this mapping.""" metadata: Final = _guardrail_request_metadata({}, {"user_api_key_alias": "billing-app"}) @@ -2614,7 +2651,7 @@ class _MetadataRecordingGuardrail(CustomGuardrail): async def apply_guardrail( self, inputs: GenericGuardrailAPIInputs, - request_data: dict, + request_data: dict[str, object], input_type: Literal["request", "response"], logging_obj: Optional[object] = None, ) -> GenericGuardrailAPIInputs: @@ -2668,6 +2705,7 @@ async def test_apply_guardrail_replaces_caller_identity_with_the_authenticated_k }, UserAPIKeyAuth(key_alias="billing-app", user_id="u-1"), ) + json.dumps(forwarded) assert forwarded["forbidden_topics"] == ["tax"] assert forwarded.get("user_api_key_alias") == "billing-app" assert "victim@example.com" not in forwarded.values() @@ -2685,8 +2723,9 @@ async def test_apply_guardrail_attaches_key_identity_when_caller_sends_no_metada @pytest.mark.asyncio async def test_apply_guardrail_prefers_the_agent_the_key_was_invoked_as() -> None: """A key bound to an agent can be invoked as another, and the invoked one is who is acting.""" - auth: Final = UserAPIKeyAuth(agent_id="support-agent") - auth.invoked_agent_id = "billing-agent" + auth: Final = UserAPIKeyAuth(agent_id="support-agent").model_copy( + update={"invoked_agent_id": "billing-agent"} + ) forwarded: Final = await _metadata_the_guardrail_was_handed(None, auth) assert forwarded.get("agent_id") == "billing-agent" From 39ba956e71147fbcdd5c8a831760263f7ff5ab94 Mon Sep 17 00:00:00 2001 From: Alessandro Pignati Date: Fri, 6 Nov 2026 11:30:57 +0100 Subject: [PATCH 13/13] style(guardrails): drop the docstrings this change added AGENTS.md keeps comments out unless they explain complex business logic, and these three repeated the function names. --- litellm/proxy/guardrails/guardrail_endpoints.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 268cb26168e..1871048577c 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -2368,13 +2368,11 @@ _SECRET_HEADER_MARKERS: Final = ("authorization", "cookie", "key", "token", "sec def _is_secret_header(name: str) -> bool: - """Whether a header name carries a credential rather than request context.""" lowered: Final = name.lower() return any(marker in lowered for marker in _SECRET_HEADER_MARKERS) def _is_json_safe(value: object) -> bool: - """Whether a value survives the JSON encoding a guardrail vendor puts it through.""" try: json.dumps(value) except (TypeError, ValueError): @@ -2383,7 +2381,6 @@ def _is_json_safe(value: object) -> bool: def _forwarded_identity_value(key: str, value: object) -> object: - """The value to forward, with the credential headers taken out of the header bucket.""" if key != "headers": return value return {name: item for name, item in _metadata_fields(value).items() if not _is_secret_header(name)} @@ -2409,9 +2406,7 @@ def _guardrail_request_metadata( The caller's bucket goes through the sanitizer the LLM routes use, so this endpoint drops the same fields they do, and what that leaves is then overlaid with the identity the proxy - resolved. A vendor receives this encoded as JSON, so only JSON-safe values are forwarded, - which leaves the authenticated key object behind, and the headers go without the ones - carrying a credential. + resolved. The request route is withheld from both sides. The tool policy guardrail reads the request itself when a route is present, instead of the tools replayed to it, so a route here turns