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 <yucheng@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 23:27:38 -07:00 committed by GitHub
parent 721d39f476
commit 320ad73f56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 581 additions and 25 deletions

View file

@ -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)
]

View file

@ -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(

View file

@ -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,
),
)

View file

@ -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}"
)

View file

@ -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()]

View file

@ -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)]

View file

@ -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():
"""