From 320ad73f568f9b4a82038f5fc2faeaefd211829e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:27:38 -0700 Subject: [PATCH] fix(policy_engine): keep inherited parent guardrails when a child policy condition misses (#42548) * fix(policy_engine): keep inherited parent guardrails when a child policy condition misses Attachment applicability now walks the policy inheritance chain, so an attached child whose own condition does not match still contributes the guardrails of its unconditional ancestors, and a non-default attachment that applies through an ancestor still suppresses default attachments. The resolver continues to skip only the chain members whose own condition fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(policy_engine): skip a policy's pipeline when its own condition misses resolve_pipelines_for_context returned the pipeline of a matched policy without evaluating its own condition, so a condition-missing child admitted by the chain-aware matcher still ran its pipeline. It now mirrors resolve_policy_guardrails and drops the pipeline when the policy's own condition does not match. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(policy_engine): property test that chain matching only widens to applicable ancestors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(policy_engine): log policies admitted only through an inherited ancestor Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(policy_engine): log ancestor admissions once per attachment scan Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/policy_engine/policy_matcher.py | 76 ++++--- .../proxy/policy_engine/policy_resolver.py | 6 + tests/e2e/guardrails/guardrails_client.py | 74 ++++++- .../test_policy_inherited_guardrail_e2e.py | 127 ++++++++++++ .../policy_engine/test_policy_matcher.py | 188 ++++++++++++++++++ .../policy_engine/test_policy_resolver.py | 54 +++++ .../proxy/test_litellm_pre_call_utils.py | 81 ++++++++ 7 files changed, 581 insertions(+), 25 deletions(-) create mode 100644 tests/e2e/guardrails/test_policy_inherited_guardrail_e2e.py diff --git a/litellm/proxy/policy_engine/policy_matcher.py b/litellm/proxy/policy_engine/policy_matcher.py index e0f558b5085..2ea2def8331 100644 --- a/litellm/proxy/policy_engine/policy_matcher.py +++ b/litellm/proxy/policy_engine/policy_matcher.py @@ -12,6 +12,7 @@ from typing import Final from litellm._logging import verbose_proxy_logger from litellm.proxy.auth.route_checks import RouteChecks +from litellm.proxy.policy_engine.policy_resolver import PolicyResolver from litellm.types.proxy.policy_engine import Policy, PolicyMatchContext, PolicyScope @@ -136,14 +137,46 @@ class PolicyMatcher: context: PolicyMatchContext, policies: dict[str, Policy] | None = None, ) -> Callable[[str], bool]: - """Predicate telling whether a policy exists and its condition matches the context.""" + """ + Predicate telling whether a policy exists and any policy in its + inheritance chain applies to the context. Admissions where the + policy's own condition missed but an ancestor applies are logged at + INFO, once per attachment scan. + """ resolved: Final = policies if policies is not None else PolicyMatcher._registry_policies() - return lambda policy_name: bool( - PolicyMatcher.get_policies_with_matching_conditions( - policy_names=(policy_name,), - context=context, - policies=resolved, + + def applies(policy_name: str) -> bool: + applying: Final = PolicyMatcher._applying_chain_members( + policy_name=policy_name, context=context, policies=resolved ) + if applying and policy_name not in applying: + verbose_proxy_logger.info( + "Policy '%s' applied through ancestor '%s' although its own condition did not match " + "(team_alias=%s, key_alias=%s, model=%s)", + policy_name, + applying[0], + context.team_alias, + context.key_alias, + context.model, + ) + return bool(applying) + + return applies + + @staticmethod + def _applying_chain_members( + policy_name: str, + context: PolicyMatchContext, + policies: dict[str, Policy], + ) -> tuple[str, ...]: + from litellm.proxy.policy_engine.condition_evaluator import ConditionEvaluator + + chain: Final = PolicyResolver.resolve_inheritance_chain(policy_name=policy_name, policies=policies) + return tuple( + name + for name in chain + if (policy := policies.get(name)) is not None + and (policy.condition is None or ConditionEvaluator.evaluate(policy.condition, context)) ) @staticmethod @@ -160,11 +193,14 @@ class PolicyMatcher: policies: dict[str, Policy] | None = None, ) -> list[str]: """ - Filter policies to only those whose conditions match the context. + Filter policies to only those that apply to the given context. - A policy's condition matches if: - - The policy has no condition (condition is None), OR - - The policy's condition evaluates to True for the given context + A policy applies when any policy in its inheritance chain has no + condition or a condition that evaluates to True for the context. The + resolver then drops only the chain members whose own condition fails, + so a child whose condition misses still contributes the guardrails of + its unconditional ancestors. A missing policy resolves to an empty + chain and does not apply. Args: policy_names: List of policy names to filter @@ -172,19 +208,11 @@ class PolicyMatcher: policies: Dictionary of all policies (if None, uses global registry) Returns: - List of policy names whose conditions match the context + List of policy names that apply to the context """ - from litellm.proxy.policy_engine.condition_evaluator import ConditionEvaluator - resolved: Final = policies if policies is not None else PolicyMatcher._registry_policies() - - matching_policies: Final = [] - for policy_name in policy_names: - policy = resolved.get(policy_name) - if policy is None: - continue - # Policy matches if it has no condition OR condition evaluates to True - if policy.condition is None or ConditionEvaluator.evaluate(policy.condition, context): - matching_policies.append(policy_name) - - return matching_policies + return [ + policy_name + for policy_name in policy_names + if PolicyMatcher._applying_chain_members(policy_name, context, resolved) + ] diff --git a/litellm/proxy/policy_engine/policy_resolver.py b/litellm/proxy/policy_engine/policy_resolver.py index 70503f85b03..e1422d79e15 100644 --- a/litellm/proxy/policy_engine/policy_resolver.py +++ b/litellm/proxy/policy_engine/policy_resolver.py @@ -210,6 +210,7 @@ class PolicyResolver: Returns: List of (policy_name, GuardrailPipeline) tuples """ + from litellm.proxy.policy_engine.condition_evaluator import ConditionEvaluator from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher from litellm.proxy.policy_engine.policy_registry import get_policy_registry @@ -230,6 +231,11 @@ class PolicyResolver: policy = policies.get(policy_name) if policy is None: continue + if policy.condition is not None and not ConditionEvaluator.evaluate( + condition=policy.condition, context=context + ): + verbose_proxy_logger.debug("Policy '%s' condition did not match, skipping pipeline", policy_name) + continue if policy.pipeline is not None: pipelines.append((policy_name, policy.pipeline)) verbose_proxy_logger.debug( diff --git a/tests/e2e/guardrails/guardrails_client.py b/tests/e2e/guardrails/guardrails_client.py index 60f875ccb7d..17223dc36fa 100644 --- a/tests/e2e/guardrails/guardrails_client.py +++ b/tests/e2e/guardrails/guardrails_client.py @@ -17,6 +17,7 @@ from models import ( AnthropicMessagesResponse, ChatBody, ChatMessage, + ChatMetadata, ChatResponse, ChatTool, KeyGenerateBody, @@ -133,6 +134,31 @@ class GuardrailCreateResponse(BaseModel): guardrail_id: str +class PolicyConditionBody(BaseModel): + model: str + + +class PolicyCreateBody(BaseModel): + policy_name: str + inherit: str | None = None + guardrails_add: list[str] + condition: PolicyConditionBody | None = None + + +class PolicyCreateResponse(BaseModel): + policy_id: str + policy_name: str + + +class PolicyAttachmentCreateBody(BaseModel): + policy_name: str + tags: list[str] + + +class PolicyAttachmentCreateResponse(BaseModel): + attachment_id: str + + class ApplyGuardrailRequest(BaseModel): guardrail_name: str text: str @@ -243,6 +269,49 @@ class GuardrailsClient: response_type=NoBody, ) + def create_policy(self, body: PolicyCreateBody) -> str: + """Create a policy via POST /policies and return its name once every replica + can be expected to serve it (policies reach the data plane on the periodic + DB sync, same as guardrails).""" + created = unwrap( + self.proxy.transport.post( + "/policies", + headers=self.proxy.transport.master, + json=body, + response_type=PolicyCreateResponse, + ) + ) + settle_propagation(time.monotonic()) + return created.policy_name + + def delete_policy(self, policy_name: str) -> None: + _ = self.proxy.transport.delete( + f"/policies/name/{policy_name}/all-versions", + headers=self.proxy.transport.master, + json=NoBody(), + response_type=NoBody, + ) + + def attach_policy_to_tags(self, policy_name: str, tags: list[str]) -> str: + attachment_id = unwrap( + self.proxy.transport.post( + "/policies/attachments", + headers=self.proxy.transport.master, + json=PolicyAttachmentCreateBody(policy_name=policy_name, tags=tags), + response_type=PolicyAttachmentCreateResponse, + ) + ).attachment_id + settle_propagation(time.monotonic()) + return attachment_id + + def delete_policy_attachment(self, attachment_id: str) -> None: + _ = self.proxy.transport.delete( + f"/policies/attachments/{attachment_id}", + headers=self.proxy.transport.master, + json=NoBody(), + response_type=NoBody, + ) + def create_team_opted_out_of_global_guardrails(self, alias: str) -> str: team_id = unwrap( self.proxy.transport.post( @@ -322,11 +391,13 @@ class GuardrailsClient: max_tokens: int = 16, tools: list[ChatTool] | None = None, tool_choice: str | None = None, + tags: list[str] | None = None, ) -> StreamingResponse: """Drive /chat/completions returning the raw HTTP outcome, for the assertions a typed body cannot carry: the `x-litellm-applied-guardrails` response header, which is how an ALLOW scenario proves the guardrail ran - rather than being absent.""" + rather than being absent. `tags` land in `metadata.tags`, which is what a + tag-scoped policy attachment matches on.""" return self.proxy.transport.send( "/chat/completions", headers=self.proxy.transport.bearer(key), @@ -337,6 +408,7 @@ class GuardrailsClient: guardrails=guardrails, tools=tools, tool_choice=tool_choice, + metadata=ChatMetadata(tags=tags) if tags is not None else None, ), ) diff --git a/tests/e2e/guardrails/test_policy_inherited_guardrail_e2e.py b/tests/e2e/guardrails/test_policy_inherited_guardrail_e2e.py new file mode 100644 index 00000000000..6298a1de038 --- /dev/null +++ b/tests/e2e/guardrails/test_policy_inherited_guardrail_e2e.py @@ -0,0 +1,127 @@ +"""Live e2e: a policy attached to a request keeps its inherited parent guardrails +when only the child's own `condition` fails to match the request model. + +The parent policy has no condition and adds a content filter. The child inherits +it, adds a second content filter, and carries a model condition. The attachment +points at the child only, so the parent is reachable through inheritance alone. +A request the child condition does not match must still be blocked by the +parent's filter; a request it does match must be blocked by both. + +Uses litellm_content_filter (keyword match, no external service) so the block is +deterministic and free, with the request model routed to a real provider. +""" + +from __future__ import annotations + +import pytest +from e2e_config import CHEAP_OPENAI_MODEL, unique_marker +from e2e_http import StreamingResponse +from guardrails_client import ( + GuardrailsClient, + PolicyConditionBody, + PolicyCreateBody, +) +from lifecycle import ResourceManager + +pytestmark = pytest.mark.e2e + +MODEL = CHEAP_OPENAI_MODEL + + +def _applied_guardrails(outcome: StreamingResponse) -> frozenset[str]: + return frozenset( + name.strip() for name in outcome.headers.get("x-litellm-applied-guardrails", "").split(",") if name.strip() + ) + + +def _setup_child_policy_attached_to_tag( + client: GuardrailsClient, + resources: ResourceManager, + *, + child_condition_model: str, + parent_banned: str, + child_banned: str, + tag: str, +) -> tuple[str, str]: + """Register parent and child content filters, a parent policy adding the parent + filter, a child policy inheriting it with `child_condition_model`, and attach + only the child to `tag`. Returns (parent_guardrail_name, child_guardrail_name).""" + parent_guardrail = f"e2e-parent-guard-{parent_banned}" + child_guardrail = f"e2e-child-guard-{child_banned}" + parent_guardrail_id = client.create_content_filter_guardrail(parent_guardrail, parent_banned, default_on=False) + resources.defer(lambda: client.delete_guardrail(parent_guardrail_id)) + child_guardrail_id = client.create_content_filter_guardrail(child_guardrail, child_banned, default_on=False) + resources.defer(lambda: client.delete_guardrail(child_guardrail_id)) + + parent_policy = client.create_policy( + PolicyCreateBody(policy_name=f"e2e-parent-policy-{parent_banned}", guardrails_add=[parent_guardrail]) + ) + resources.defer(lambda: client.delete_policy(parent_policy)) + child_policy = client.create_policy( + PolicyCreateBody( + policy_name=f"e2e-child-policy-{child_banned}", + inherit=parent_policy, + guardrails_add=[child_guardrail], + condition=PolicyConditionBody(model=child_condition_model), + ) + ) + resources.defer(lambda: client.delete_policy(child_policy)) + + attachment_id = client.attach_policy_to_tags(child_policy, [tag]) + resources.defer(lambda: client.delete_policy_attachment(attachment_id)) + return parent_guardrail, child_guardrail + + +class TestPolicyInheritedGuardrail: + def test_child_condition_miss_still_applies_inherited_parent_guardrail( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + parent_banned = unique_marker() + child_banned = unique_marker() + tag = f"e2e-policy-tag-{unique_marker()}" + parent_guardrail, child_guardrail = _setup_child_policy_attached_to_tag( + client, + resources, + child_condition_model=f"never-matches-{unique_marker()}", + parent_banned=parent_banned, + child_banned=child_banned, + tag=tag, + ) + + outcome = client.chat_raw(scoped_key, MODEL, f"Reply with the single word OK. {parent_banned}", tags=[tag]) + + assert outcome.status_code == 400, ( + f"the inherited parent content filter must block the banned keyword even though the child " + f"policy's own model condition does not match {MODEL}; got {outcome.status_code}: {outcome.body[:300]}" + ) + assert parent_guardrail in _applied_guardrails(outcome), ( + f"x-litellm-applied-guardrails must name the inherited parent guardrail; got {outcome.headers}" + ) + assert child_guardrail not in _applied_guardrails(outcome), ( + f"the child's own guardrail must not run when its condition fails; got {outcome.headers}" + ) + + def test_child_condition_match_applies_child_and_inherited_parent_guardrails( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + parent_banned = unique_marker() + child_banned = unique_marker() + tag = f"e2e-policy-tag-{unique_marker()}" + parent_guardrail, child_guardrail = _setup_child_policy_attached_to_tag( + client, + resources, + child_condition_model=MODEL, + parent_banned=parent_banned, + child_banned=child_banned, + tag=tag, + ) + + outcome = client.chat_raw(scoped_key, MODEL, f"Reply with the single word OK. {child_banned}", tags=[tag]) + + assert outcome.status_code == 400, ( + f"the child's own content filter must block its banned keyword when the condition matches {MODEL}; " + f"got {outcome.status_code}: {outcome.body[:300]}" + ) + assert {parent_guardrail, child_guardrail} <= _applied_guardrails(outcome), ( + f"both the child and inherited parent guardrails must run; got {outcome.headers}" + ) diff --git a/tests/test_litellm/proxy/policy_engine/test_policy_matcher.py b/tests/test_litellm/proxy/policy_engine/test_policy_matcher.py index b07137893ec..27153e67ab5 100644 --- a/tests/test_litellm/proxy/policy_engine/test_policy_matcher.py +++ b/tests/test_litellm/proxy/policy_engine/test_policy_matcher.py @@ -6,14 +6,23 @@ Tests: - Scope matching via attachments (teams, keys, models) """ +import logging +from typing import Final + import pytest +from hypothesis import given, settings +from hypothesis import strategies as st import litellm.proxy.policy_engine.attachment_registry as attachment_registry_module import litellm.proxy.policy_engine.policy_registry as policy_registry_module from litellm.proxy.policy_engine.attachment_registry import AttachmentRegistry from litellm.proxy.policy_engine.policy_matcher import PolicyMatcher from litellm.proxy.policy_engine.policy_registry import PolicyRegistry +from litellm.proxy.policy_engine.policy_resolver import PolicyResolver from litellm.types.proxy.policy_engine import ( + Policy, + PolicyCondition, + PolicyGuardrails, PolicyMatchContext, PolicyScope, ) @@ -221,6 +230,34 @@ def _global_registries(monkeypatch): return policies +def _inherited_registries(monkeypatch, parent_condition=None): + policies = PolicyRegistry() + policies.load_policies( + { + "parent": { + "guardrails": {"add": ["y"]}, + **({"condition": parent_condition} if parent_condition else {}), + }, + "child": { + "inherit": "parent", + "guardrails": {"add": ["x"]}, + "condition": {"model": "claude.*"}, + }, + "fallback": {"guardrails": {"add": ["z"]}}, + } + ) + attachments = AttachmentRegistry() + attachments.load_attachments( + [ + {"policy": "child", "scope": "*"}, + {"policy": "fallback", "scope": "*", "default": True}, + ] + ) + monkeypatch.setattr(policy_registry_module, "get_policy_registry", lambda: policies) + monkeypatch.setattr(attachment_registry_module, "get_attachment_registry", lambda: attachments) + return policies + + class TestGetMatchingPoliciesFallback: def test_condition_failing_opt_in_falls_back_to_default(self, monkeypatch): _global_registries(monkeypatch) @@ -244,3 +281,154 @@ class TestGetMatchingPoliciesFallback: PolicyMatcher.get_matching_policies(context=context) assert len(calls) == 1 + + def test_condition_missing_child_with_unconditional_parent_still_matches(self, monkeypatch): + _inherited_registries(monkeypatch) + context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + + assert PolicyMatcher.get_matching_policies(context=context) == ["child"] + + def test_child_whose_whole_chain_misses_falls_back_to_default(self, monkeypatch): + _inherited_registries(monkeypatch, parent_condition={"model": "claude.*"}) + context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + + assert PolicyMatcher.get_matching_policies(context=context) == ["fallback"] + + def test_get_policies_with_matching_conditions_keeps_missing_policy_out(self): + policies = { + "real": Policy( + guardrails=PolicyGuardrails(add=["g"]), + condition=PolicyCondition(model="claude.*"), + ), + } + context = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + + assert ( + PolicyMatcher.get_policies_with_matching_conditions( + policy_names=["nope"], context=context, policies=policies + ) + == [] + ) + + +_MODELS: Final = ("gpt-4o", "gpt-5.5", "claude-opus-4-1") + + +def _policy_forest(draw: st.DrawFn) -> dict[str, Policy]: # mutable-ok: PolicyResolver takes dict[str, Policy] + names: Final = tuple(f"p{i}" for i in range(draw(st.integers(min_value=1, max_value=6)))) + return { # mutable-ok: PolicyResolver takes dict[str, Policy] + name: Policy( + inherit=draw(st.sampled_from((None, *names[:i]))), + guardrails=PolicyGuardrails(add=[f"g-{name}"]), # mutable-ok: pydantic list field + condition=draw(st.sampled_from((None, *(PolicyCondition(model=m) for m in _MODELS)))), + ) + for i, name in enumerate(names) + } + + +@st.composite +def _forest_and_request( + draw: st.DrawFn, +) -> tuple[dict[str, Policy], tuple[str, ...], PolicyMatchContext]: # mutable-ok: PolicyResolver takes dict + policies: Final = _policy_forest(draw) + attached: Final = tuple(draw(st.lists(st.sampled_from(sorted(policies)), unique=True))) + context: Final = PolicyMatchContext(team_alias="t", key_alias="k", model=draw(st.sampled_from(_MODELS))) + return policies, attached, context + + +def _own_condition_applies(policy: Policy, context: PolicyMatchContext) -> bool: + return policy.condition is None or policy.condition.model == context.model + + +def _applicable_chain( + policies: dict[str, Policy], # mutable-ok: PolicyResolver takes dict[str, Policy] + name: str, + context: PolicyMatchContext, +) -> tuple[str, ...]: + chain: Final = PolicyResolver.resolve_inheritance_chain(policy_name=name, policies=policies) + return tuple(member for member in chain if _own_condition_applies(policies[member], context)) + + +class TestChainMatchingProperties: + @given(_forest_and_request()) + @settings(max_examples=400, deadline=None) + def test_chain_matching_only_widens_to_applicable_ancestor_guardrails( + self, + case: tuple[dict[str, Policy], tuple[str, ...], PolicyMatchContext], # mutable-ok: PolicyResolver takes dict + ): + policies, attached, context = case + head: Final = tuple( + PolicyMatcher.get_policies_with_matching_conditions( + policy_names=attached, context=context, policies=policies + ) + ) + base: Final = tuple(name for name in attached if _own_condition_applies(policies[name], context)) + expected_head: Final = tuple(name for name in attached if _applicable_chain(policies, name, context)) + + assert head == expected_head, "a policy applies exactly when some chain member's own condition applies" + assert frozenset(base) <= frozenset(head), "head must never drop a policy base applied" + + for name in head: + resolved = PolicyResolver.resolve_policy_guardrails(policy_name=name, policies=policies, context=context) + assert sorted(resolved.guardrails) == sorted( + f"g-{member}" for member in _applicable_chain(policies, name, context) + ) + if name not in base: + assert f"g-{name}" not in resolved.guardrails, "a condition-missed child must not add its own guardrail" + + +class TestAncestorAdmissionLogging: + @staticmethod + def _chain() -> dict[str, Policy]: # mutable-ok: PolicyResolver takes dict[str, Policy] + return { # mutable-ok: PolicyResolver takes dict[str, Policy] + "parent": Policy(guardrails=PolicyGuardrails(add=["g-parent"])), # mutable-ok: pydantic list field + "child": Policy( + inherit="parent", + guardrails=PolicyGuardrails(add=["g-child"]), # mutable-ok: pydantic list field + condition=PolicyCondition(model="gpt-5.5"), + ), + } + + def test_logs_when_admitted_through_ancestor_only(self, caplog): + context: Final = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-4o") + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + result: Final = PolicyMatcher.policy_applies(context, self._chain())("child") + records: Final = [r for r in caplog.records if "applied through ancestor" in r.getMessage()] + assert result is True + assert len(records) == 1 + assert "applied through ancestor 'parent'" in records[0].getMessage() + assert "'child'" in records[0].getMessage() + + def test_no_log_when_own_condition_matches(self, caplog): + context: Final = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + result: Final = PolicyMatcher.policy_applies(context, self._chain())("child") + assert result is True + assert not [r for r in caplog.records if "applied through ancestor" in r.getMessage()] + + def test_no_log_when_no_chain_member_applies(self, caplog): + policies: Final = { # mutable-ok: PolicyResolver takes dict[str, Policy] + "parent": Policy( + guardrails=PolicyGuardrails(add=["g-parent"]), # mutable-ok: pydantic list field + condition=PolicyCondition(model="claude-opus-4-1"), + ), + "child": Policy( + inherit="parent", + guardrails=PolicyGuardrails(add=["g-child"]), # mutable-ok: pydantic list field + condition=PolicyCondition(model="gpt-5.5"), + ), + } + context: Final = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-4o") + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + result: Final = PolicyMatcher.policy_applies(context, policies)("child") + assert result is False + assert not [r for r in caplog.records if "applied through ancestor" in r.getMessage()] + + def test_condition_filter_logs_nothing(self, caplog): + context: Final = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-4o") + with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"): + result: Final = PolicyMatcher.get_policies_with_matching_conditions( + policy_names=["child"], context=context, policies=self._chain() + ) + assert result == ["child"] + assert not [r for r in caplog.records if "applied through ancestor" in r.getMessage()] diff --git a/tests/test_litellm/proxy/policy_engine/test_policy_resolver.py b/tests/test_litellm/proxy/policy_engine/test_policy_resolver.py index b9ce22d749e..3d2f547a744 100644 --- a/tests/test_litellm/proxy/policy_engine/test_policy_resolver.py +++ b/tests/test_litellm/proxy/policy_engine/test_policy_resolver.py @@ -11,6 +11,8 @@ import pytest from litellm.proxy.policy_engine.policy_resolver import PolicyResolver from litellm.types.proxy.policy_engine import ( + GuardrailPipeline, + PipelineStep, Policy, PolicyCondition, PolicyGuardrails, @@ -199,3 +201,55 @@ class TestPolicyResolverWithConditions: ) assert "pii_blocker" in resolved_gpt35.guardrails assert "child_guardrail" not in resolved_gpt35.guardrails + + def test_resolve_guardrails_for_context_with_condition_missing_child_keeps_inherited_parent(self): + """Test a matched child whose condition misses still contributes unconditional parent guardrails.""" + policies = { + "parent": Policy( + guardrails=PolicyGuardrails(add=["y"]), + ), + "child": Policy( + inherit="parent", + guardrails=PolicyGuardrails(add=["x"]), + condition=PolicyCondition(model="claude.*"), + ), + } + + context_miss = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + assert PolicyResolver.resolve_guardrails_for_context( + context=context_miss, policies=policies, policy_names=["child"] + ) == ["y"] + + context_hit = PolicyMatchContext(team_alias="t", key_alias="k", model="claude-haiku") + assert set( + PolicyResolver.resolve_guardrails_for_context( + context=context_hit, policies=policies, policy_names=["child"] + ) + ) == {"x", "y"} + + def test_resolve_pipelines_for_context_skips_pipeline_when_own_condition_misses(self): + """Test a matched child whose own condition misses does not run its pipeline.""" + pipeline = GuardrailPipeline(mode="pre_call", steps=[PipelineStep(guardrail="child-guard")]) + policies = { + "parent": Policy( + guardrails=PolicyGuardrails(add=["y"]), + ), + "child": Policy( + inherit="parent", + pipeline=pipeline, + condition=PolicyCondition(model="gpt-5.5"), + ), + } + + context_miss = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-4o") + assert ( + PolicyResolver.resolve_pipelines_for_context( + context=context_miss, policies=policies, policy_names=["child"] + ) + == [] + ) + + context_hit = PolicyMatchContext(team_alias="t", key_alias="k", model="gpt-5.5") + assert PolicyResolver.resolve_pipelines_for_context( + context=context_hit, policies=policies, policy_names=["child"] + ) == [("child", pipeline)] diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 8fb53d4b0a0..0d4b9e8d21f 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -4377,6 +4377,45 @@ def test_match_and_track_policies_preserves_attachment_and_request_body_order(): assert applied_policy_names == policy_names +def test_match_and_track_policies_keeps_condition_missing_child_alongside_unconditional_sibling(): + from litellm.proxy.policy_engine.attachment_registry import AttachmentRegistry + from litellm.types.proxy.policy_engine import ( + Policy, + PolicyCondition, + PolicyGuardrails, + PolicyMatchContext, + ) + + policies = { + "baseline": Policy(guardrails=PolicyGuardrails(add=["baseline_guardrail"])), + "parent": Policy(guardrails=PolicyGuardrails(add=["pii_blocker"])), + "child": Policy( + inherit="parent", + guardrails=PolicyGuardrails(add=["child_guard"]), + condition=PolicyCondition(model="claude.*"), + ), + } + attachment_registry = AttachmentRegistry() + attachment_registry.load_attachments( + [ + {"policy": "baseline", "scope": "*"}, + {"policy": "child", "scope": "*"}, + ] + ) + data = {"metadata": {}} + + applied_policy_names, _ = _match_and_track_policies( + data=data, + context=PolicyMatchContext(model="gpt-5.5"), + request_body_policies=[], + policies_override=policies, + attachment_registry_override=attachment_registry, + ) + + assert applied_policy_names == ["baseline", "child"] + assert data["metadata"]["applied_policies"] == ["baseline", "child"] + + @pytest.mark.asyncio async def test_add_guardrails_from_policy_engine_keeps_a_policy_added_guardrail_its_pipeline_also_steps(): from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry @@ -4419,6 +4458,48 @@ async def test_add_guardrails_from_policy_engine_keeps_a_policy_added_guardrail_ assert [pipeline.mode for _policy_name, pipeline in data["metadata"]["_guardrail_pipelines"]] == ["post_call"] +@pytest.mark.asyncio +async def test_add_guardrails_from_policy_engine_applies_inherited_parent_guardrail_when_child_condition_misses(): + from litellm.proxy.policy_engine.attachment_registry import get_attachment_registry + from litellm.proxy.policy_engine.policy_registry import get_policy_registry + from litellm.types.proxy.policy_engine import ( + Policy, + PolicyAttachment, + PolicyCondition, + PolicyGuardrails, + ) + + data = {"model": "gpt-5.5", "messages": [{"role": "user", "content": "Hello"}], "metadata": {}} + policy_registry = get_policy_registry() + policy_registry._policies = { + "parent": Policy(guardrails=PolicyGuardrails(add=["pii_blocker"])), + "child": Policy( + inherit="parent", + guardrails=PolicyGuardrails(add=["child_guard"]), + condition=PolicyCondition(model="claude.*"), + ), + } + policy_registry._initialized = True + attachment_registry = get_attachment_registry() + attachment_registry._attachments = [PolicyAttachment(policy="child", scope="*")] + attachment_registry._initialized = True + + try: + await add_guardrails_from_policy_engine( + data=data, + metadata_variable_name="metadata", + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), + ) + finally: + policy_registry._policies = {} + policy_registry._initialized = False + attachment_registry._attachments = [] + attachment_registry._initialized = False + + assert "pii_blocker" in data["metadata"]["guardrails"] + assert "child_guard" not in data["metadata"]["guardrails"] + + @pytest.mark.asyncio async def test_add_guardrails_from_policy_engine_accepts_dynamic_policies_and_pops_from_data(): """