From 2e1929787f01a52a40f23ffa19ff4be89275dee2 Mon Sep 17 00:00:00 2001 From: albertbausili Date: Fri, 16 Oct 2026 09:27:44 +0200 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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",