refactor(guardrails): keep agent 365 fail closed by default and make fail_open an explicit opt-in

Restores the shared unreachable_fallback default and the sibling guardrail initializers, drops the Admin UI YAML preview that only existed for the per-guardrail default, and reworks the unit and integration tests so the default blocks with HTTP 503 while unreachable_fallback: fail_open lets availability failures through as Unscanned

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-26 08:05:11 +00:00
parent ff59e6d25b
commit 27f1013806
19 changed files with 203 additions and 340 deletions

View file

@ -10405,20 +10405,14 @@
"title": "Timeout"
},
"unreachable_fallback": {
"anyOf": [
{
"enum": [
"fail_closed",
"fail_open"
],
"type": "string"
},
{
"type": "null"
}
"default": "fail_closed",
"description": "Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.",
"enum": [
"fail_closed",
"fail_open"
],
"description": "Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.",
"title": "Unreachable Fallback"
"title": "Unreachable Fallback",
"type": "string"
},
"violation_message_template": {
"anyOf": [
@ -13279,20 +13273,14 @@
"title": "Tracker Api Key"
},
"unreachable_fallback": {
"anyOf": [
{
"enum": [
"fail_closed",
"fail_open"
],
"type": "string"
},
{
"type": "null"
}
"default": "fail_closed",
"description": "Behavior when the headroom compression service is unreachable or errors. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and forwards the request uncompressed instead of blocking it.",
"enum": [
"fail_closed",
"fail_open"
],
"description": "Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.",
"title": "Unreachable Fallback"
"title": "Unreachable Fallback",
"type": "string"
},
"use_v2": {
"anyOf": [

View file

@ -54,7 +54,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
agent_id=litellm_params.agent_id,
authority_host=authority_host,
request_timeout=litellm_params.timeout if litellm_params.timeout is not None else 10.0,
unreachable_fallback=litellm_params.unreachable_fallback or "fail_open",
unreachable_fallback=litellm_params.unreachable_fallback,
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
)

View file

@ -159,7 +159,7 @@ class Agent365Guardrail(CustomGuardrail):
agent_id: str | None = None,
authority_host: str = AGENT_365_DEFAULT_AUTHORITY_HOST,
request_timeout: float = 10.0,
unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_open",
unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed",
async_handler: AsyncHTTPHandler | None = None,
**kwargs, # noqa: ANN003 # kwargs-ok: forwarded verbatim to CustomGuardrail (event_hook, default_on)
) -> None:
@ -180,7 +180,7 @@ class Agent365Guardrail(CustomGuardrail):
self.authority_host = authority if "://" in authority else f"https://{authority}"
self.request_timeout = request_timeout
self.unreachable_fallback: Literal["fail_closed", "fail_open"] = (
"fail_closed" if unreachable_fallback == "fail_closed" else "fail_open"
"fail_open" if unreachable_fallback == "fail_open" else "fail_closed"
)
self.async_handler = async_handler or get_async_httpx_client(
llm_provider=httpxSpecialProvider.GuardrailCallback

View file

@ -16,7 +16,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
akto_api_key=getattr(litellm_params, "akto_api_key", None),
akto_account_id=getattr(litellm_params, "akto_account_id", None),
akto_vxlan_id=getattr(litellm_params, "akto_vxlan_id", None),
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"),
guardrail_timeout=getattr(litellm_params, "guardrail_timeout", None),
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,

View file

@ -14,7 +14,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
_alice_guardrail_callback: Final = AliceGuardrail(
api_key=litellm_params.api_key,
api_base=litellm_params.api_base,
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"),
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,

View file

@ -29,7 +29,7 @@ def initialize_guardrail(
agent_token=litellm_params.api_key,
workspace_id=extras.get("workspace_id"),
tool_name=extras.get("tool_name", "llm_call"),
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=litellm_params.unreachable_fallback,
timeout=DEFAULT_TIMEOUT_SECONDS if litellm_params.timeout is None else litellm_params.timeout,
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,

View file

@ -15,7 +15,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
api_base=litellm_params.api_base,
api_key=litellm_params.api_key,
firewall_id=getattr(litellm_params, "deepkeep_firewall_id", None),
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"),
extra_headers=getattr(litellm_params, "extra_headers", None),
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,

View file

@ -30,7 +30,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
api_key=litellm_params.api_key,
headers=getattr(litellm_params, "headers", None),
additional_provider_specific_params=getattr(litellm_params, "additional_provider_specific_params", {}),
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"),
fail_on_error=getattr(litellm_params, "fail_on_error", True),
extra_headers=getattr(litellm_params, "extra_headers", None),
guardrail_name=guardrail.get("guardrail_name", ""),

View file

@ -30,7 +30,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
api_key=litellm_params.api_key,
api_base=litellm_params.api_base,
asset_id=litellm_params.asset_id,
unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed",
unreachable_fallback=litellm_params.unreachable_fallback,
event_hook=_event_hook_from_mode(litellm_params.mode),
default_on=litellm_params.default_on or False,
)

View file

@ -55,7 +55,9 @@ def initialize_guardrail(litellm_params: LitellmParams, guardrail: Guardrail) ->
guardrail_name=guardrail["guardrail_name"],
event_hook=_coerce_event_hook(litellm_params.mode),
default_on=litellm_params.default_on or False,
unreachable_fallback=litellm_params.unreachable_fallback,
unreachable_fallback=(
litellm_params.unreachable_fallback if "unreachable_fallback" in litellm_params.model_fields_set else None
),
)
litellm.logging_callback_manager.add_litellm_callback( # pyright: ignore[reportUnknownMemberType] # callback manager is untyped
_callback

View file

@ -1055,13 +1055,12 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up
description="Additional provider-specific parameters for generic guardrail APIs",
)
unreachable_fallback: Literal["fail_closed", "fail_open"] | None = Field(
default=None,
unreachable_fallback: Literal["fail_closed", "fail_open"] = Field(
default="fail_closed",
description=(
"Behavior when a guardrail endpoint is unreachable due to network errors. "
"Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. "
"'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. "
"Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed."
"'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed."
),
)
@ -1205,13 +1204,6 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o
mode: str | list[str] | Mode = Field(
description="When to apply the guardrail (pre_call, post_call, during_call, logging_only)"
)
unreachable_fallback: Literal["fail_closed", "fail_open"] | None = Field( # pyright: ignore[reportIncompatibleVariableOverride] # mixins pin a default; unset defers to the guardrail's own
default=None,
description=(
"Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. "
"Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed."
),
)
@field_validator("timeout", mode="before", check_fields=False)
@classmethod

View file

@ -72,13 +72,12 @@ class Agent365GuardrailConfigModel(GuardrailConfigModel):
)
unreachable_fallback: Literal["fail_closed", "fail_open"] = Field(
default="fail_open",
default="fail_closed",
description=(
"Behavior when Agent 365 or Entra is unreachable, times out, returns 5xx, skips the evaluation, or "
"rejects the gateway's own client credentials. 'fail_open' (default) allows the tool call and records "
"it as Unscanned in the logs and OpenTelemetry. "
"'fail_closed' blocks it with HTTP 503. Policy blocks, 4xx rejections, throttling and a rejected "
"caller token always block."
"rejects the gateway's own client credentials. 'fail_closed' (default) blocks the tool call with HTTP 503. "
"'fail_open' allows it, logs an error and records it as Unscanned in the logs and OpenTelemetry. "
"Policy blocks, 4xx rejections, throttling and a rejected caller token always block."
),
)

View file

@ -265,14 +265,50 @@ def _chat(rig: Rig, model: str, marker: str) -> httpx.Response:
)
def test_default_lets_the_call_through_unscanned_when_agent_365_cannot_evaluate(
def test_default_blocks_with_503_and_never_reaches_upstream_when_agent_365_cannot_evaluate(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
unavailable: Final = "could not authorize the tool call"
outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1})
assert outage.text == '{"a": 1}', f"Agent 365 down must fail open by default: {outage.raw}"
assert outage.error is not None and unavailable in outage.raw, (
f"Agent 365 down must block by default: {outage.raw}"
)
skipped: Final = rig.caller.call(f"{rig.alias}-skipped", {"a": 2})
assert skipped.text == '{"a": 2}', f"Defender skipping the call must fail open by default: {skipped.raw}"
assert skipped.error is not None and unavailable in skipped.raw, (
f"Defender skip must block by default: {skipped.raw}"
)
denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 3})
assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw
assert rig.caller.call(f"{rig.alias}-add", {"a": 4}).text == '{"a": 4}'
assert rig.upstream_tool_names() == ("add",)
statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 4, seconds=70)
assert statuses == {
"outage": "guardrail_failed_to_respond",
"skipped": "guardrail_failed_to_respond",
"denied": "guardrail_intervened",
"add": "success",
}, statuses
def test_explicit_fail_closed_matches_the_default(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback="fail_closed") as rig:
outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1})
assert outage.error is not None and "could not authorize the tool call" in outage.raw, outage.raw
assert rig.caller.call(f"{rig.alias}-add", {"a": 2}).text == '{"a": 2}'
assert rig.upstream_tool_names() == ("add",)
statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70)
assert statuses == {"outage": "guardrail_failed_to_respond", "add": "success"}, statuses
def test_opted_in_fail_open_lets_the_call_through_unscanned_and_still_blocks_policy_denials(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1})
assert outage.text == '{"a": 1}', f"Agent 365 down must fail open once opted in: {outage.raw}"
skipped: Final = rig.caller.call(f"{rig.alias}-skipped", {"a": 2})
assert skipped.text == '{"a": 2}', f"Defender skipping the call must fail open once opted in: {skipped.raw}"
denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 3})
assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw
assert rig.upstream_tool_names() == ("outage", "skipped")
@ -284,32 +320,8 @@ def test_default_lets_the_call_through_unscanned_when_agent_365_cannot_evaluate(
}, statuses
def test_explicit_fail_closed_blocks_with_503_and_never_reaches_upstream_when_agent_365_is_down(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback="fail_closed") as rig:
outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1})
assert outage.error is not None and "could not authorize the tool call" in outage.raw, outage.raw
assert rig.caller.call(f"{rig.alias}-add", {"a": 2}).text == '{"a": 2}'
assert rig.upstream_tool_names() == ("add",)
statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70)
assert statuses == {"outage": "guardrail_failed_to_respond", "add": "success"}, statuses
def test_explicit_fail_open_matches_the_default_and_still_blocks_policy_denials(
gateway: Gateway, tmp_path: Path
) -> None:
def test_opted_in_fail_open_covers_malformed_or_stalled_agent_365_replies(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
assert rig.caller.call(f"{rig.alias}-outage", {"a": 1}).text == '{"a": 1}'
denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 2})
assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw
assert rig.upstream_tool_names() == ("outage",)
statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70)
assert statuses == {"outage": "guardrail_failed_to_respond", "denied": "guardrail_intervened"}, statuses
def test_default_fails_open_on_malformed_or_stalled_agent_365_replies(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
for index, tool in enumerate(("nonjson", "nobool", "slow")):
outcome: Final = rig.caller.call(f"{rig.alias}-{tool}", {"a": index})
assert outcome.text == json.dumps({"a": index}), f"{tool}: {outcome.raw}"
@ -318,10 +330,10 @@ def test_default_fails_open_on_malformed_or_stalled_agent_365_replies(gateway: G
assert statuses == dict.fromkeys(("nonjson", "nobool", "slow"), "guardrail_failed_to_respond"), statuses
def test_default_fails_open_when_entra_is_down_stalled_malformed_or_refuses_the_gateway_credentials(
def test_opted_in_fail_open_covers_entra_down_stalled_malformed_or_refusing_the_gateway_credentials(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
cases: Final = ("entra-outage", "entra-slow", "entra-nonjson", "entra-misconfigured")
for index, case in enumerate(cases):
outcome: Final = rig.caller_for("mcp", _caller_token(case)).call(f"{rig.alias}-add", {"a": index})
@ -339,10 +351,10 @@ def test_default_fails_open_when_entra_is_down_stalled_malformed_or_refuses_the_
assert [row["gi"][0]["guardrail_status"] for row in rows] == ["guardrail_failed_to_respond"] * len(cases)
def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_under_the_default(
def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_even_when_opted_in_to_fail_open(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
throttled: Final = rig.caller.call(f"{rig.alias}-throttled", {"a": 1})
assert throttled.error == "Error: Agent 365 guardrail could not authorize the tool call", throttled.raw
rejected: Final = rig.caller.call(f"{rig.alias}-rejected", {"a": 2})
@ -352,8 +364,10 @@ def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_under_the_defa
assert statuses == {"throttled": "guardrail_failed_to_respond", "rejected": "guardrail_intervened"}, statuses
def test_caller_authentication_failures_keep_blocking_under_the_default(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
def test_caller_authentication_failures_keep_blocking_even_when_opted_in_to_fail_open(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
rejected: Final = "Error: Agent 365 guardrail rejected the tool call"
missing: Final = rig.call_without_bearer("add")
assert missing.error == rejected, missing.raw
@ -372,8 +386,8 @@ def test_caller_authentication_failures_keep_blocking_under_the_default(gateway:
assert [row["gi"][0]["guardrail_status"] for row in rows] == ["guardrail_intervened"] * 3
def test_every_mcp_entry_point_fails_open_on_outage_and_blocks_denials(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback=None) as rig:
def test_every_mcp_entry_point_honors_the_fail_open_opt_in_and_blocks_denials(gateway: Gateway, tmp_path: Path) -> None:
with _rig(gateway, tmp_path, fallback="fail_open") as rig:
for entry in ENTRY_POINTS:
caller: Final = rig.caller_for(entry)
passed: Final = caller.call(f"{rig.alias}-outage", {"entry": entry}, server_id=rig.server_id)
@ -383,10 +397,10 @@ def test_every_mcp_entry_point_fails_open_on_outage_and_blocks_denials(gateway:
assert rig.upstream_tool_names() == ("outage",) * len(ENTRY_POINTS)
def test_chat_completions_never_touch_agent_365_while_a_sibling_guardrail_keeps_its_fail_closed_default(
def test_agent_365_fail_open_opt_in_does_not_leak_to_a_sibling_guardrail_on_chat_completions(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None, sibling=True) as rig, rig.candidate.scenario() as scenario:
with _rig(gateway, tmp_path, fallback="fail_open", sibling=True) as rig, rig.candidate.scenario() as scenario:
model: Final = scenario.model()
chat: Final = _chat(rig, model, "sibling-" + uuid.uuid4().hex)
assert chat.status_code == 500 and "Generic Guardrail API failed" in chat.text, chat.text
@ -433,7 +447,7 @@ def test_agent365_authority_host_env_wins_over_azure_authority_host_when_config_
def test_thirty_call_burst_against_a_flapping_agent_365_reaches_upstream_exactly_once_each_on_two_workers(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None, workers=2) as rig:
with _rig(gateway, tmp_path, fallback="fail_open", workers=2) as rig:
markers: Final = tuple(("outage" if index % 2 else "add", uuid.uuid4().hex) for index in range(30))
with ThreadPoolExecutor(max_workers=10) as pool:
outcomes: Final = tuple(
@ -449,10 +463,10 @@ def test_thirty_call_burst_against_a_flapping_agent_365_reaches_upstream_exactly
assert rig.upstream_tool_names() == ()
def test_default_survives_a_worker_kill_and_keeps_blocking_denials_on_two_workers(
def test_opted_in_fail_open_survives_a_worker_kill_and_keeps_blocking_denials_on_two_workers(
gateway: Gateway, tmp_path: Path
) -> None:
with _rig(gateway, tmp_path, fallback=None, workers=2) as rig:
with _rig(gateway, tmp_path, fallback="fail_open", workers=2) as rig:
before: Final = rig.every_worker_serves_the_catalog(2)
victim: Final = min(before)
os.kill(victim, signal.SIGKILL)

View file

@ -234,31 +234,19 @@ class TestInitializeGuardrail:
assert guardrail.client_secret == "env-secret"
assert guardrail.api_base == "https://env.example.test"
assert guardrail.resource_app_id == AGENT_365_PROD_RESOURCE_APP_ID
assert guardrail.unreachable_fallback == "fail_open"
assert guardrail.unreachable_fallback == "fail_closed"
def test_unset_fallback_survives_a_model_dump_round_trip(self):
stored: Final = LitellmParams(
guardrail="agent_365", mode="pre_mcp_call", tenant_id="t", client_id="c", client_secret="s"
).model_dump()
assert stored["unreachable_fallback"] is None
guardrail: Final = initialize_guardrail(LitellmParams(**stored), {"guardrail_name": "a365-db"})
assert guardrail.unreachable_fallback == "fail_open"
def test_explicit_fail_closed_is_kept(self):
def test_fail_open_is_opt_in_through_litellm_params(self):
params: Final = LitellmParams(
guardrail="agent_365",
mode="pre_mcp_call",
tenant_id="t",
client_id="c",
client_secret="s",
unreachable_fallback="fail_closed",
unreachable_fallback="fail_open",
)
guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-closed"})
assert guardrail.unreachable_fallback == "fail_closed"
def test_agent_365_default_leaves_other_guardrails_unset(self):
assert LitellmParams(guardrail="generic_guardrail_api", mode="pre_call").unreachable_fallback is None
assert Agent365GuardrailConfigModel.model_fields["unreachable_fallback"].default == "fail_open"
guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-open"})
assert guardrail.unreachable_fallback == "fail_open"
def test_authority_host_defaults_to_public_entra(self, monkeypatch):
monkeypatch.delenv("AGENT365_AUTHORITY_HOST", raising=False)
@ -569,45 +557,51 @@ class TestDefenderNotEvaluated:
assert "rejected" in exc_info.value.detail["error"]
class TestFailOpenDefault:
AVAILABILITY_FAILURES: Final = (
pytest.param([_token_response(), httpx.ReadTimeout("timed out")], id="evaluate-timeout"),
pytest.param([_token_response(), _response(502, text="bad gateway")], id="evaluate-5xx"),
pytest.param([_token_response(), _not_evaluated_response("Skipped")], id="evaluate-skipped"),
pytest.param([_response(503, text="entra down")], id="entra-5xx"),
)
class TestOptInFailOpen:
@pytest.mark.asyncio
async def test_constructor_default_lets_timed_out_evaluation_through_unscanned(self, caplog):
handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")])
guardrail: Final = _default_fallback_guardrail(handler)
assert guardrail.unreachable_fallback == "fail_open"
@pytest.mark.parametrize("responses", AVAILABILITY_FAILURES)
async def test_constructor_default_blocks_each_availability_failure_with_503(self, responses):
guardrail: Final = _default_fallback_guardrail(FakeHandler(responses))
assert guardrail.unreachable_fallback == "fail_closed"
with pytest.raises(HTTPException) as exc_info:
await _run(guardrail, _mcp_data())
assert exc_info.value.status_code == 503
@pytest.mark.asyncio
@pytest.mark.parametrize("responses", AVAILABILITY_FAILURES)
async def test_opted_in_fail_open_lets_each_availability_failure_through_as_failed_to_respond(self, responses):
guardrail: Final = _make_guardrail(FakeHandler(responses), unreachable_fallback="fail_open")
data: Final = _mcp_data()
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
assert await _run(guardrail, data) is data
assert await _run(guardrail, data) is data
info: Final = _guardrail_info(data)
assert info["guardrail_status"] == "guardrail_failed_to_respond"
assert info["guardrail_response"]["verdict"] == "Unscanned"
@pytest.mark.asyncio
async def test_opted_in_fail_open_logs_the_unscanned_call_at_error_level(self, caplog):
handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")])
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
await _run(guardrail, _mcp_data())
fail_open_logs: Final = [r for r in caplog.records if "unreachable_fallback='fail_open'" in r.getMessage()]
assert [r.levelno for r in fail_open_logs] == [logging.ERROR], caplog.text
@pytest.mark.asyncio
async def test_constructor_default_still_blocks_a_policy_block(self):
async def test_opted_in_fail_open_still_blocks_a_policy_block(self):
handler: Final = FakeHandler([_token_response(), _block_response()])
guardrail: Final = _default_fallback_guardrail(handler)
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
with pytest.raises(HTTPException) as exc_info:
await _run(guardrail, _mcp_data())
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize(
"responses",
[
[_token_response(), httpx.ReadTimeout("timed out")],
[_token_response(), _response(502, text="bad gateway")],
[_token_response(), _not_evaluated_response("Skipped")],
[_response(503, text="entra down")],
],
)
async def test_each_availability_failure_lets_the_call_through_as_failed_to_respond(self, responses):
guardrail: Final = _default_fallback_guardrail(FakeHandler(responses))
data: Final = _mcp_data()
assert await _run(guardrail, data) is data
assert _guardrail_info(data)["guardrail_status"] == "guardrail_failed_to_respond"
class TestUnreachableFallback:
@pytest.mark.asyncio

View file

@ -1,7 +1,9 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
from litellm.types.guardrails import SupportedGuardrailIntegrations
@ -165,29 +167,6 @@ def test_initialize_guardrail_sets_run_in_parallel(config_value, expected):
assert custom_guardrail.run_in_parallel is expected
@pytest.mark.parametrize(
"guardrail, provider_params, expected",
[
("agent_365", {"tenant_id": "t", "client_id": "c", "client_secret": "s", "mode": "pre_mcp_call"}, "fail_open"),
("typesafe", {"api_key": "k"}, "fail_open"),
("generic_guardrail_api", {"api_base": "http://127.0.0.1:1/guard"}, "fail_closed"),
("akto", {"akto_base_url": "http://127.0.0.1:1", "akto_api_key": "k", "akto_account_id": "1"}, "fail_closed"),
("alice", {"api_key": "k", "api_base": "http://127.0.0.1:1"}, "fail_closed"),
("deepkeep", {"api_base": "http://127.0.0.1:1", "api_key": "k", "deepkeep_firewall_id": "f"}, "fail_closed"),
("repelloai", {"api_key": "k", "api_base": "http://127.0.0.1:1", "asset_id": "a"}, "fail_closed"),
],
)
def test_unset_unreachable_fallback_applies_each_guardrails_own_default(guardrail, provider_params, expected):
litellm_params = {"guardrail": guardrail, "mode": "pre_call", **provider_params}
guardrail_handler = InMemoryGuardrailHandler()
result = guardrail_handler.initialize_guardrail(
guardrail={"guardrail_name": f"default-fallback-{guardrail}", "litellm_params": litellm_params},
)
custom_guardrail = guardrail_handler.guardrail_id_to_custom_guardrail[result["guardrail_id"]]
assert custom_guardrail.unreachable_fallback == expected, f"{guardrail} with unreachable_fallback unset"
def test_initialize_presidio_forwards_analyze_chunk_size_bytes():
"""Regression (LIT-4785): `presidio_analyze_chunk_size_bytes` set in
config.yaml must reach the guardrail instance. The field lives on
@ -216,7 +195,8 @@ def test_initialize_presidio_forwards_analyze_chunk_size_bytes():
initialized = [
callback
for callback in litellm.callbacks
if isinstance(callback, _OPTIONAL_PresidioPIIMasking) and callback.guardrail_name == "test_presidio_chunk_size"
if isinstance(callback, _OPTIONAL_PresidioPIIMasking)
and callback.guardrail_name == "test_presidio_chunk_size"
]
assert initialized, "presidio guardrail was not registered as a callback"
assert initialized[-1].presidio_analyze_chunk_size_bytes == 250_000

View file

@ -38,7 +38,6 @@ import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isValidUrl } from "@/lib/forms/urlValidation";
import { useZodForm } from "@/lib/forms/useZodForm";
import { buildEquivalentConfigYaml, type TeamGuardrail, type TeamGuardrailStatus } from "./teamGuardrailConfigYaml";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@ -98,7 +97,32 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
</>
);
function mapStatus(apiStatus: string): TeamGuardrailStatus {
type GuardrailStatus = "active" | "pending" | "rejected";
type TeamGuardrail = {
id: string;
team: string;
name: string;
endpoint: string;
status: GuardrailStatus;
model: string;
forwardKey: boolean;
description: string;
method: "POST" | "GET";
customHeaders: {
key: string;
value: string;
}[];
extraHeaders: string[];
submittedAt: string;
submittedBy: string;
mode?: string;
unreachable_fallback?: string;
additionalProviderParams?: Record<string, unknown>;
guardrailType?: string;
};
function mapStatus(apiStatus: string): GuardrailStatus {
if (apiStatus === "pending_review") return "pending";
if (apiStatus === "active" || apiStatus === "rejected") return apiStatus;
return "active";
@ -150,13 +174,13 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail
submittedAt: formatSubmissionDate(item.submitted_at),
submittedBy: item.submitted_by_email ?? item.submitted_by_user_id ?? "—",
mode: params.mode as string | undefined,
unreachable_fallback: params.unreachable_fallback as string | null | undefined,
unreachable_fallback: params.unreachable_fallback as string | undefined,
additionalProviderParams: params.additional_provider_specific_params as Record<string, unknown> | undefined,
guardrailType: params.guardrail as string | undefined,
};
}
const STATUS_CONFIG: Record<TeamGuardrailStatus, { label: string; bg: string; text: string; dot: string }> = {
const STATUS_CONFIG: Record<GuardrailStatus, { label: string; bg: string; text: string; dot: string }> = {
active: {
label: "Active",
bg: "bg-success/10",
@ -186,6 +210,44 @@ const TEAM_COLORS: Record<string, string> = {
Finance: "bg-success/15 text-success",
};
function buildEquivalentConfigYaml(g: TeamGuardrail): string {
const lines: string[] = [
"litellm_settings:",
" guardrails:",
` - guardrail_name: "${g.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
" litellm_params:",
` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`,
` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`,
` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`,
" api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional",
` unreachable_fallback: ${g.unreachable_fallback ?? "fail_closed"} # default: fail_closed. Set to fail_open to proceed if the guardrail endpoint is unreachable.`,
` forward_api_key: ${g.forwardKey}`,
];
if (g.model && g.model !== "—") {
lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`);
}
if (g.customHeaders.length > 0) {
lines.push(" headers: # static headers (sent with every request)");
for (const h of g.customHeaders) {
lines.push(` ${h.key}: "${String(h.value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
}
}
if (g.extraHeaders.length > 0) {
lines.push(" extra_headers: # forward these client request headers to the guardrail");
for (const name of g.extraHeaders) {
lines.push(` - ${name}`);
}
}
if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) {
lines.push(" additional_provider_specific_params:");
for (const [k, v] of Object.entries(g.additionalProviderParams)) {
const val = typeof v === "string" ? `"${v}"` : String(v);
lines.push(` ${k}: ${val}`);
}
}
return lines.join("\n");
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="bg-card border border-border rounded-lg px-4 py-3">
@ -763,7 +825,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
});
const [search, setSearch] = useState("");
const [searchDebounced] = useDebouncedValue(search, { wait: DEBOUNCE_WAIT_MS });
const [statusFilter, setStatusFilter] = useState<"all" | TeamGuardrailStatus>("all");
const [statusFilter, setStatusFilter] = useState<"all" | GuardrailStatus>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [expandedHeaders, setExpandedHeaders] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<{

View file

@ -1,94 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildEquivalentConfigYaml, defaultUnreachableFallback, type TeamGuardrail } from "./teamGuardrailConfigYaml";
function guardrail(overrides: Partial<TeamGuardrail>): TeamGuardrail {
return {
id: "g-1",
team: "Security",
name: "agent365-mcp",
endpoint: "",
status: "active",
model: "—",
forwardKey: false,
description: "",
method: "POST",
customHeaders: [],
extraHeaders: [],
submittedAt: "2026-01-01",
submittedBy: "ops@example.com",
...overrides,
};
}
function fallbackLine(yaml: string): string {
const line = yaml.split("\n").find((l) => l.includes("unreachable_fallback:"));
if (line === undefined) throw new Error(`no unreachable_fallback line in:\n${yaml}`);
return line.trim().split(" #")[0];
}
describe("defaultUnreachableFallback", () => {
it("fails open for agent_365 and typesafe, closed for everything else including unknown", () => {
expect(defaultUnreachableFallback("agent_365")).toBe("fail_open");
expect(defaultUnreachableFallback("typesafe")).toBe("fail_open");
expect(defaultUnreachableFallback("generic_guardrail_api")).toBe("fail_closed");
expect(defaultUnreachableFallback("akto")).toBe("fail_closed");
expect(defaultUnreachableFallback(undefined)).toBe("fail_closed");
});
});
describe("buildEquivalentConfigYaml unreachable_fallback line", () => {
it("shows fail_open for an agent_365 guardrail with no explicit fallback", () => {
const yaml = buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365" }));
expect(fallbackLine(yaml)).toBe("unreachable_fallback: fail_open");
expect(yaml).toContain(" guardrail: agent_365");
});
it("shows fail_open for a typesafe guardrail with no explicit fallback", () => {
expect(fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "typesafe" })))).toBe(
"unreachable_fallback: fail_open",
);
});
it("shows fail_closed for a generic guardrail and when the type is unknown", () => {
expect(fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "generic_guardrail_api" })))).toBe(
"unreachable_fallback: fail_closed",
);
const untyped = buildEquivalentConfigYaml(guardrail({}));
expect(fallbackLine(untyped)).toBe("unreachable_fallback: fail_closed");
expect(untyped).toContain(" guardrail: generic_guardrail_api");
});
it("treats a null fallback from the API as unset and shows the per-guardrail default", () => {
expect(
fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365", unreachable_fallback: null }))),
).toBe("unreachable_fallback: fail_open");
expect(
fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "akto", unreachable_fallback: null }))),
).toBe("unreachable_fallback: fail_closed");
});
it("keeps an explicit override over the per-guardrail default", () => {
expect(
fallbackLine(
buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365", unreachable_fallback: "fail_closed" })),
),
).toBe("unreachable_fallback: fail_closed");
expect(
fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "akto", unreachable_fallback: "fail_open" }))),
).toBe("unreachable_fallback: fail_open");
});
it("explains the shown value is the guardrail's default only when nothing was set explicitly", () => {
const rawLine = (g: TeamGuardrail) =>
buildEquivalentConfigYaml(g)
.split("\n")
.find((l) => l.includes("unreachable_fallback:"));
expect(rawLine(guardrail({ guardrailType: "agent_365" }))).toBe(
" unreachable_fallback: fail_open # fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable. Shown value is this guardrail's default.",
);
expect(rawLine(guardrail({ guardrailType: "agent_365", unreachable_fallback: "fail_closed" }))).toBe(
" unreachable_fallback: fail_closed # fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable",
);
});
});

View file

@ -1,78 +0,0 @@
export type TeamGuardrailStatus = "active" | "pending" | "rejected";
export type TeamGuardrail = {
id: string;
team: string;
name: string;
endpoint: string;
status: TeamGuardrailStatus;
model: string;
forwardKey: boolean;
description: string;
method: "POST" | "GET";
customHeaders: {
key: string;
value: string;
}[];
extraHeaders: string[];
submittedAt: string;
submittedBy: string;
mode?: string;
unreachable_fallback?: string | null;
additionalProviderParams?: Record<string, unknown>;
guardrailType?: string;
};
const FAIL_OPEN_BY_DEFAULT_GUARDRAILS: ReadonlySet<string> = new Set(["agent_365", "typesafe"]);
export function defaultUnreachableFallback(guardrailType: string | undefined): "fail_open" | "fail_closed" {
return guardrailType !== undefined && FAIL_OPEN_BY_DEFAULT_GUARDRAILS.has(guardrailType)
? "fail_open"
: "fail_closed";
}
function unreachableFallbackLine(g: TeamGuardrail): string {
const hint = "fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable";
if (g.unreachable_fallback) {
return ` unreachable_fallback: ${g.unreachable_fallback} # ${hint}`;
}
return ` unreachable_fallback: ${defaultUnreachableFallback(g.guardrailType)} # ${hint}. Shown value is this guardrail's default.`;
}
export function buildEquivalentConfigYaml(g: TeamGuardrail): string {
const lines: string[] = [
"litellm_settings:",
" guardrails:",
` - guardrail_name: "${g.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`,
" litellm_params:",
` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`,
` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`,
` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`,
" api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional",
unreachableFallbackLine(g),
` forward_api_key: ${g.forwardKey}`,
];
if (g.model && g.model !== "—") {
lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`);
}
if (g.customHeaders.length > 0) {
lines.push(" headers: # static headers (sent with every request)");
for (const h of g.customHeaders) {
lines.push(` ${h.key}: "${String(h.value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
}
}
if (g.extraHeaders.length > 0) {
lines.push(" extra_headers: # forward these client request headers to the guardrail");
for (const name of g.extraHeaders) {
lines.push(` - ${name}`);
}
}
if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) {
lines.push(" additional_provider_specific_params:");
for (const [k, v] of Object.entries(g.additionalProviderParams)) {
const val = typeof v === "string" ? `"${v}"` : String(v);
lines.push(` ${k}: ${val}`);
}
}
return lines.join("\n");
}

View file

@ -25570,9 +25570,11 @@ export interface components {
timeout?: number | null;
/**
* Unreachable Fallback
* @description Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.
* @description Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.
* @default fail_closed
* @enum {string}
*/
unreachable_fallback?: ("fail_closed" | "fail_open") | null;
unreachable_fallback: "fail_closed" | "fail_open";
/**
* Violation Message Template
* @description Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}.
@ -34794,9 +34796,11 @@ export interface components {
tracker_api_key?: string | null;
/**
* Unreachable Fallback
* @description Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.
* @description Behavior when the headroom compression service is unreachable or errors. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and forwards the request uncompressed instead of blocking it.
* @default fail_closed
* @enum {string}
*/
unreachable_fallback?: ("fail_closed" | "fail_open") | null;
unreachable_fallback: "fail_closed" | "fail_open";
/**
* Use V2
* @description If True and guardrail='noma', route to the new Noma v2 implementation instead of the legacy implementation.