mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge 28467d404a into 1df25e26cf
This commit is contained in:
commit
57366342b9
9 changed files with 1469 additions and 1 deletions
|
|
@ -0,0 +1,61 @@
|
|||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import (
|
||||
AGENT_365_PROD_API_BASE,
|
||||
AGENT_365_PROD_RESOURCE_APP_ID,
|
||||
)
|
||||
|
||||
from .agent_365 import Agent365Guardrail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
|
||||
def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail") -> Agent365Guardrail:
|
||||
import litellm
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
tenant_id: Final = litellm_params.tenant_id or get_secret_str("AGENT365_TENANT_ID")
|
||||
client_id: Final = litellm_params.client_id or get_secret_str("AGENT365_CLIENT_ID")
|
||||
client_secret: Final = (
|
||||
litellm_params.client_secret or litellm_params.api_key or get_secret_str("AGENT365_CLIENT_SECRET")
|
||||
)
|
||||
api_base: Final = litellm_params.api_base or get_secret_str("AGENT365_API_BASE")
|
||||
resource_app_id: Final = litellm_params.resource_app_id or get_secret_str("AGENT365_RESOURCE_APP_ID")
|
||||
|
||||
if not tenant_id:
|
||||
raise ValueError("Microsoft Agent 365: tenant_id is required")
|
||||
if not client_id:
|
||||
raise ValueError("Microsoft Agent 365: client_id is required")
|
||||
if not client_secret:
|
||||
raise ValueError("Microsoft Agent 365: client_secret (or api_key) is required")
|
||||
|
||||
guardrail_name: Final = guardrail.get("guardrail_name")
|
||||
if not guardrail_name:
|
||||
raise ValueError("Microsoft Agent 365: guardrail_name is required")
|
||||
|
||||
agent_365_guardrail: Final = Agent365Guardrail(
|
||||
guardrail_name=guardrail_name,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
api_base=api_base or AGENT_365_PROD_API_BASE,
|
||||
resource_app_id=resource_app_id or AGENT_365_PROD_RESOURCE_APP_ID,
|
||||
agent_id=litellm_params.agent_id,
|
||||
request_timeout=litellm_params.timeout if litellm_params.timeout is not None else 10.0,
|
||||
unreachable_fallback=litellm_params.unreachable_fallback,
|
||||
event_hook=litellm_params.mode,
|
||||
default_on=litellm_params.default_on,
|
||||
)
|
||||
litellm.logging_callback_manager.add_litellm_callback(agent_365_guardrail)
|
||||
return agent_365_guardrail
|
||||
|
||||
|
||||
guardrail_initializer_registry: Final = { # mutable-ok: registry auto-discovery requires a dict instance
|
||||
SupportedGuardrailIntegrations.AGENT_365.value: initialize_guardrail,
|
||||
}
|
||||
|
||||
guardrail_class_registry: Final = { # mutable-ok: registry auto-discovery requires a dict instance
|
||||
SupportedGuardrailIntegrations.AGENT_365.value: Agent365Guardrail,
|
||||
}
|
||||
575
litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py
Normal file
575
litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
"""Microsoft Agent 365 governance guardrail for MCP tool calls.
|
||||
|
||||
Before the gateway executes an MCP tool, the pending call is sent to the
|
||||
Agent 365 tool-evaluation endpoint, where Microsoft Defender scores it and
|
||||
Agent 365 records it for observability. The returned allow/block verdict is
|
||||
enforced here. Authentication is the Entra On-Behalf-Of flow: the caller's
|
||||
incoming bearer token (audienced to this gateway's app registration) is
|
||||
exchanged for a delegated Agent 365 token, so Defender evaluates and audits
|
||||
as the signed-in user.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, ClassVar, Final, Literal, NoReturn
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.exceptions import Timeout as LitellmTimeout
|
||||
from litellm.integrations.custom_guardrail import (
|
||||
CustomGuardrail,
|
||||
log_guardrail_information,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import (
|
||||
AGENT_365_PROD_API_BASE,
|
||||
AGENT_365_PROD_RESOURCE_APP_ID,
|
||||
AGENT_365_SCOPE_NAME,
|
||||
Agent365GuardrailConfigModel,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
|
||||
from litellm.types.utils import GuardrailStatus
|
||||
|
||||
TOKEN_ENDPOINT_TEMPLATE: Final = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
EVALUATE_PATH: Final = "/agents/tool-evaluation/evaluate"
|
||||
MCP_SESSION_ID_HEADER: Final = "mcp-session-id"
|
||||
_MCP_CALL_TYPES: Final[tuple[str, ...]] = ("mcp_call", "call_mcp_tool")
|
||||
_OBO_CACHE_MAX_ENTRIES: Final = 1000
|
||||
_DEFAULT_TOKEN_TTL_SECONDS: Final = 3599.0
|
||||
_TOKEN_EXPIRY_SLACK_SECONDS: Final = 60.0
|
||||
|
||||
|
||||
def _parse_expires_in(raw: object) -> float:
|
||||
if not isinstance(raw, (int, float, str)):
|
||||
return _DEFAULT_TOKEN_TTL_SECONDS
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_TOKEN_TTL_SECONDS
|
||||
|
||||
|
||||
class _DefenderResult(TypedDict, total=False):
|
||||
status: ReadOnly[str]
|
||||
verdict: ReadOnly[str | None]
|
||||
message: ReadOnly[str | None]
|
||||
|
||||
|
||||
class _EvaluateResponse(TypedDict, total=False):
|
||||
allowed: ReadOnly[bool]
|
||||
defender: ReadOnly[_DefenderResult]
|
||||
correlationId: ReadOnly[str]
|
||||
|
||||
|
||||
class _ToolReference(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
|
||||
|
||||
class _UnavailableDetail(TypedDict):
|
||||
error: ReadOnly[str]
|
||||
message: ReadOnly[str]
|
||||
tool: ReadOnly[str]
|
||||
|
||||
|
||||
class _BlockedDetail(TypedDict):
|
||||
error: ReadOnly[str]
|
||||
message: ReadOnly[str]
|
||||
tool: ReadOnly[str]
|
||||
correlation_id: ReadOnly[str | None]
|
||||
|
||||
|
||||
class Agent365TokenExchangeError(Exception):
|
||||
def __init__(self, status_code: int, error_code: str, description: str) -> None:
|
||||
super().__init__(f"{error_code}: {description}")
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
self.description = description
|
||||
|
||||
|
||||
class Agent365MalformedResponseError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Agent365ThrottledError(Exception):
|
||||
def __init__(self, status_code: int) -> None:
|
||||
super().__init__(f"HTTP {status_code}")
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class Agent365Guardrail(CustomGuardrail):
|
||||
"""Pre-MCP-call guardrail enforcing Microsoft Agent 365 tool-evaluation verdicts."""
|
||||
|
||||
records_own_guardrail_information: ClassVar[bool] = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
guardrail_name: str,
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
api_base: str = AGENT_365_PROD_API_BASE,
|
||||
resource_app_id: str = AGENT_365_PROD_RESOURCE_APP_ID,
|
||||
agent_id: str | None = None,
|
||||
request_timeout: float = 10.0,
|
||||
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:
|
||||
super().__init__(
|
||||
guardrail_name=guardrail_name,
|
||||
supported_event_hooks=self.get_supported_event_hooks(),
|
||||
**kwargs,
|
||||
)
|
||||
self.guardrail_provider = "agent_365"
|
||||
self.tenant_id = tenant_id
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.resource_app_id = resource_app_id
|
||||
self.agent_id = agent_id
|
||||
self.request_timeout = request_timeout
|
||||
self.unreachable_fallback: Literal["fail_closed", "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
|
||||
)
|
||||
self._obo_token_cache: OrderedDict[str, tuple[str, float]] = OrderedDict() # mutable-ok: lock-guarded LRU
|
||||
self._obo_cache_lock = threading.Lock()
|
||||
verbose_proxy_logger.info("Initialized Microsoft Agent 365 guardrail: %s", guardrail_name)
|
||||
|
||||
@staticmethod
|
||||
def get_config_model() -> "type[GuardrailConfigModel] | None":
|
||||
return Agent365GuardrailConfigModel
|
||||
|
||||
@classmethod
|
||||
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]: # mutable-ok: CustomGuardrail contract
|
||||
return [GuardrailEventHooks.pre_mcp_call] # mutable-ok: CustomGuardrail contract expects a list
|
||||
|
||||
@log_guardrail_information
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
cache: "DualCache",
|
||||
data: dict, # mutable-ok: hook contract; guardrail logging appends into the request metadata in place
|
||||
call_type: str,
|
||||
) -> Exception | str | dict | None: # mutable-ok: CustomGuardrail.async_pre_call_hook contract
|
||||
if call_type not in _MCP_CALL_TYPES:
|
||||
return data
|
||||
if "mcp_tool_name" not in data:
|
||||
return data
|
||||
if self.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_mcp_call) is not True:
|
||||
return data
|
||||
|
||||
tool_name: Final = str(data.get("mcp_tool_name") or "")
|
||||
assertion: Final = data.get("incoming_bearer_token")
|
||||
if not isinstance(assertion, str) or assertion.count(".") != 2:
|
||||
self._handle_caller_fault(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
status_code=401,
|
||||
reason=(
|
||||
"the caller did not present an Entra bearer token; the Agent 365 guardrail "
|
||||
"authorizes tool calls On-Behalf-Of the signed-in user"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
obo_token: Final = await self._get_obo_token(assertion)
|
||||
except Agent365TokenExchangeError as exc:
|
||||
self._handle_caller_fault(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
status_code=401,
|
||||
reason=f"the Entra On-Behalf-Of token exchange was rejected ({exc.error_code})",
|
||||
)
|
||||
except Agent365ThrottledError as exc:
|
||||
self._handle_throttled(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=f"the Entra token endpoint returned HTTP {exc.status_code}",
|
||||
latency_ms=None,
|
||||
)
|
||||
except (httpx.HTTPError, LitellmTimeout, TimeoutError) as exc:
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=f"the Entra token endpoint could not be reached ({type(exc).__name__})",
|
||||
)
|
||||
except Agent365MalformedResponseError as exc:
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=str(exc),
|
||||
)
|
||||
|
||||
start: Final = time.perf_counter()
|
||||
try:
|
||||
response: Final = await self._post_allowing_error_status(
|
||||
url=f"{self.api_base}{EVALUATE_PATH}",
|
||||
json=self._build_evaluate_payload(data=data, user_api_key_dict=user_api_key_dict),
|
||||
headers={"Authorization": f"Bearer {obo_token}"}, # mutable-ok: httpx header dict
|
||||
)
|
||||
except (httpx.HTTPError, LitellmTimeout, TimeoutError) as exc:
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=f"the Agent 365 endpoint could not be reached ({type(exc).__name__})",
|
||||
)
|
||||
latency_ms: Final = (time.perf_counter() - start) * 1000.0
|
||||
fallback: Final = self._handle_evaluate_error(
|
||||
data=data, tool_name=tool_name, assertion=assertion, response=response, latency_ms=latency_ms
|
||||
)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
return self._enforce_verdict(data=data, tool_name=tool_name, response=response, latency_ms=latency_ms)
|
||||
|
||||
def _handle_evaluate_error(
|
||||
self,
|
||||
data: dict, # mutable-ok: guardrail logging appends into the request metadata in place
|
||||
tool_name: str,
|
||||
assertion: str,
|
||||
response: httpx.Response,
|
||||
latency_ms: float,
|
||||
) -> dict | None: # mutable-ok: returns the request data dict per hook contract on fail_open
|
||||
if response.status_code in (408, 429):
|
||||
self._handle_throttled(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=f"the Agent 365 endpoint returned HTTP {response.status_code}",
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
if 400 <= response.status_code < 500:
|
||||
if response.status_code == 401:
|
||||
self._evict_obo_token(assertion)
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Rejected",
|
||||
guardrail_status="guardrail_intervened",
|
||||
defender_status=None,
|
||||
correlation_id=None,
|
||||
latency_ms=latency_ms,
|
||||
reason=f"HTTP {response.status_code}: {response.text[:512]}",
|
||||
)
|
||||
rejected_detail: Final[_UnavailableDetail] = {
|
||||
"error": "Agent 365 rejected the tool evaluation request",
|
||||
"message": response.text[:512]
|
||||
if response.status_code == 400
|
||||
else f"the Agent 365 evaluation request failed with HTTP {response.status_code}",
|
||||
"tool": tool_name,
|
||||
}
|
||||
raise HTTPException(status_code=400, detail=rejected_detail)
|
||||
if response.status_code != 200:
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason=f"the Agent 365 endpoint returned HTTP {response.status_code}",
|
||||
)
|
||||
return None
|
||||
|
||||
def _enforce_verdict(
|
||||
self,
|
||||
data: dict, # mutable-ok: guardrail logging appends into the request metadata in place
|
||||
tool_name: str,
|
||||
response: httpx.Response,
|
||||
latency_ms: float,
|
||||
) -> dict: # mutable-ok: returns the request data dict per hook contract
|
||||
try:
|
||||
parsed_verdict: Final = response.json()
|
||||
except ValueError:
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason="the Agent 365 endpoint returned a non-JSON body",
|
||||
)
|
||||
if not isinstance(parsed_verdict, dict):
|
||||
return self._handle_unavailable(
|
||||
data=data,
|
||||
tool_name=tool_name,
|
||||
reason="the Agent 365 endpoint returned a non-object JSON body",
|
||||
)
|
||||
verdict: Final[_EvaluateResponse] = parsed_verdict
|
||||
raw_defender: Final = verdict.get("defender")
|
||||
defender: Final = raw_defender if isinstance(raw_defender, dict) else _DefenderResult()
|
||||
raw_correlation_id: Final = verdict.get("correlationId")
|
||||
correlation_id: Final = raw_correlation_id if isinstance(raw_correlation_id, str) else None
|
||||
allowed: Final = verdict.get("allowed") is True
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Allow" if allowed else "Block",
|
||||
guardrail_status="success" if allowed else "guardrail_intervened",
|
||||
defender_status=defender.get("status"),
|
||||
correlation_id=correlation_id,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
if not allowed:
|
||||
blocked_detail: Final[_BlockedDetail] = {
|
||||
"error": "Blocked by Microsoft Defender",
|
||||
"message": (
|
||||
defender.get("message")
|
||||
or f"Invocation of '{tool_name}' is blocked by Microsoft Threat Detection policies "
|
||||
"configured by your administrator."
|
||||
),
|
||||
"tool": tool_name,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
raise HTTPException(status_code=400, detail=blocked_detail)
|
||||
return data
|
||||
|
||||
def _build_evaluate_payload(
|
||||
self,
|
||||
data: Mapping[str, object],
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
) -> dict[str, object]: # mutable-ok: JSON body for AsyncHTTPHandler.post, which requires dict
|
||||
tool_name: Final = str(data.get("mcp_tool_name") or "")
|
||||
arguments: Final = data.get("mcp_arguments")
|
||||
server_name: Final = str(data.get("mcp_server_name") or "litellm")
|
||||
agent_id: Final = self.agent_id or getattr(user_api_key_dict, "key_alias", None)
|
||||
tool_reference: Final[_ToolReference] = {"name": tool_name}
|
||||
payload: Final[dict[str, object]] = { # mutable-ok: JSON body with optional fields added below
|
||||
"tool": tool_reference,
|
||||
"serverName": server_name,
|
||||
"conversationId": self._resolve_conversation_id(data),
|
||||
}
|
||||
if isinstance(arguments, dict):
|
||||
payload["arguments"] = arguments
|
||||
if agent_id:
|
||||
payload["agentId"] = str(agent_id)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _resolve_conversation_id(data: Mapping[str, object]) -> str:
|
||||
metadata: Final = next(
|
||||
(m for m in (data.get("metadata"), data.get("litellm_metadata")) if isinstance(m, Mapping)),
|
||||
None,
|
||||
)
|
||||
headers: Final = metadata.get("headers") if isinstance(metadata, Mapping) else None
|
||||
if isinstance(headers, Mapping):
|
||||
session_id: Final = next(
|
||||
(value for name, value in headers.items() if str(name).lower() == MCP_SESSION_ID_HEADER),
|
||||
None,
|
||||
)
|
||||
if isinstance(session_id, str) and session_id:
|
||||
return session_id
|
||||
logging_obj: Final = data.get("litellm_logging_obj")
|
||||
call_details: Final = getattr(logging_obj, "model_call_details", None)
|
||||
if isinstance(call_details, Mapping):
|
||||
tool_call_metadata: Final = call_details.get("mcp_tool_call_metadata")
|
||||
session_from_logging: Final = (
|
||||
tool_call_metadata.get("mcp_session_id") if isinstance(tool_call_metadata, Mapping) else None
|
||||
)
|
||||
if isinstance(session_from_logging, str) and session_from_logging:
|
||||
return session_from_logging
|
||||
call_id: Final = data.get("litellm_call_id") or getattr(logging_obj, "litellm_call_id", None)
|
||||
if isinstance(call_id, str) and call_id:
|
||||
return call_id
|
||||
return str(uuid.uuid4())
|
||||
|
||||
async def _get_obo_token(self, assertion: str) -> str:
|
||||
cache_key: Final = hashlib.sha256(assertion.encode("utf-8")).hexdigest()
|
||||
now: Final = time.time()
|
||||
with self._obo_cache_lock:
|
||||
cached: Final = self._obo_token_cache.get(cache_key)
|
||||
if cached and cached[1] > now + _TOKEN_EXPIRY_SLACK_SECONDS:
|
||||
self._obo_token_cache.move_to_end(cache_key)
|
||||
return cached[0]
|
||||
|
||||
response: Final = await self._post_allowing_error_status(
|
||||
url=TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=self.tenant_id),
|
||||
data={ # mutable-ok: OAuth form body; AsyncHTTPHandler.post requires dict
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"assertion": assertion,
|
||||
"scope": f"{self.resource_app_id}/{AGENT_365_SCOPE_NAME}",
|
||||
"requested_token_use": "on_behalf_of",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}, # mutable-ok: httpx header dict
|
||||
)
|
||||
if response.status_code in (408, 429):
|
||||
raise Agent365ThrottledError(status_code=response.status_code)
|
||||
if response.status_code >= 500:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Entra token endpoint returned {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
try:
|
||||
parsed_body: Final = response.json()
|
||||
except ValueError as exc:
|
||||
raise Agent365MalformedResponseError("the Entra token endpoint returned a non-JSON body") from exc
|
||||
if not isinstance(parsed_body, dict):
|
||||
raise Agent365MalformedResponseError("the Entra token endpoint returned a non-object JSON body")
|
||||
body: Final = parsed_body
|
||||
if response.status_code >= 400:
|
||||
raise Agent365TokenExchangeError(
|
||||
status_code=response.status_code,
|
||||
error_code=str(body.get("error", "invalid_grant")),
|
||||
description=str(body.get("error_description", ""))[:512],
|
||||
)
|
||||
if "access_token" not in body:
|
||||
raise Agent365MalformedResponseError("the Entra token endpoint returned no access_token")
|
||||
raw_access_token: Final = body.get("access_token")
|
||||
if not isinstance(raw_access_token, str) or not raw_access_token:
|
||||
raise Agent365MalformedResponseError("the Entra token endpoint returned a non-string access_token")
|
||||
access_token: Final = raw_access_token
|
||||
expires_at: Final = time.time() + _parse_expires_in(body.get("expires_in", 3599))
|
||||
with self._obo_cache_lock:
|
||||
self._obo_token_cache[cache_key] = (access_token, expires_at)
|
||||
self._obo_token_cache.move_to_end(cache_key)
|
||||
while len(self._obo_token_cache) > _OBO_CACHE_MAX_ENTRIES:
|
||||
self._obo_token_cache.popitem(last=False)
|
||||
return access_token
|
||||
|
||||
async def _post_allowing_error_status(
|
||||
self,
|
||||
url: str,
|
||||
headers: dict[str, str], # mutable-ok: AsyncHTTPHandler.post requires dict
|
||||
data: dict[str, str] | None = None, # mutable-ok: AsyncHTTPHandler.post requires dict
|
||||
json: dict[str, object] | None = None, # mutable-ok: AsyncHTTPHandler.post requires dict
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
return await self.async_handler.post(
|
||||
url=url,
|
||||
data=data,
|
||||
json=json,
|
||||
headers=headers,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return exc.response
|
||||
|
||||
def _handle_caller_fault(
|
||||
self,
|
||||
data: dict, # mutable-ok: guardrail logging appends into the request metadata in place
|
||||
tool_name: str,
|
||||
status_code: int,
|
||||
reason: str,
|
||||
) -> NoReturn:
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Rejected",
|
||||
guardrail_status="guardrail_intervened",
|
||||
defender_status=None,
|
||||
correlation_id=None,
|
||||
latency_ms=None,
|
||||
reason=reason,
|
||||
)
|
||||
caller_fault_detail: Final[_UnavailableDetail] = {
|
||||
"error": "Agent 365 guardrail rejected the tool call",
|
||||
"message": f"Tool call '{tool_name}' was blocked because {reason}.",
|
||||
"tool": tool_name,
|
||||
}
|
||||
raise HTTPException(status_code=status_code, detail=caller_fault_detail)
|
||||
|
||||
def _handle_throttled(
|
||||
self,
|
||||
data: dict, # mutable-ok: guardrail logging appends into the request metadata in place
|
||||
tool_name: str,
|
||||
reason: str,
|
||||
latency_ms: float | None,
|
||||
) -> NoReturn:
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Throttled",
|
||||
guardrail_status="guardrail_failed_to_respond",
|
||||
defender_status=None,
|
||||
correlation_id=None,
|
||||
latency_ms=latency_ms,
|
||||
reason=reason,
|
||||
)
|
||||
throttled_detail: Final[_UnavailableDetail] = {
|
||||
"error": "Agent 365 guardrail could not authorize the tool call",
|
||||
"message": f"Tool call '{tool_name}' was blocked because {reason}; "
|
||||
"throttled evaluations block regardless of unreachable_fallback.",
|
||||
"tool": tool_name,
|
||||
}
|
||||
raise HTTPException(status_code=503, detail=throttled_detail)
|
||||
|
||||
def _evict_obo_token(self, assertion: str) -> None:
|
||||
cache_key: Final = hashlib.sha256(assertion.encode("utf-8")).hexdigest()
|
||||
with self._obo_cache_lock:
|
||||
self._obo_token_cache.pop(cache_key, None)
|
||||
|
||||
def _handle_unavailable(
|
||||
self,
|
||||
data: dict, # mutable-ok: guardrail logging appends into the request metadata in place
|
||||
tool_name: str,
|
||||
reason: str,
|
||||
) -> dict: # mutable-ok: returns the request data dict per hook contract
|
||||
if self.unreachable_fallback == "fail_open":
|
||||
verbose_proxy_logger.warning(
|
||||
"Agent 365 guardrail (%s): %s; unreachable_fallback='fail_open', allowing tool call '%s' unscanned",
|
||||
self.guardrail_name,
|
||||
reason,
|
||||
tool_name,
|
||||
)
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Unscanned",
|
||||
guardrail_status="guardrail_failed_to_respond",
|
||||
defender_status=None,
|
||||
correlation_id=None,
|
||||
latency_ms=None,
|
||||
reason=reason,
|
||||
)
|
||||
return data
|
||||
self._record_verdict(
|
||||
data=data,
|
||||
verdict="Unavailable",
|
||||
guardrail_status="guardrail_failed_to_respond",
|
||||
defender_status=None,
|
||||
correlation_id=None,
|
||||
latency_ms=None,
|
||||
reason=reason,
|
||||
)
|
||||
unavailable_detail: Final[_UnavailableDetail] = {
|
||||
"error": "Agent 365 guardrail could not authorize the tool call",
|
||||
"message": f"Tool call '{tool_name}' was blocked because {reason} and unreachable_fallback is "
|
||||
"'fail_closed'.",
|
||||
"tool": tool_name,
|
||||
}
|
||||
raise HTTPException(status_code=503, detail=unavailable_detail)
|
||||
|
||||
def _record_verdict(
|
||||
self,
|
||||
data: dict[str, object], # mutable-ok: standard guardrail logging appends into the request metadata in place
|
||||
verdict: str,
|
||||
guardrail_status: "GuardrailStatus",
|
||||
defender_status: str | None,
|
||||
correlation_id: str | None,
|
||||
latency_ms: float | None,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
payload: Final[dict[str, object]] = {"verdict": verdict} # mutable-ok: optional fields added below
|
||||
if defender_status:
|
||||
payload["defender_status"] = defender_status
|
||||
if correlation_id:
|
||||
payload["correlation_id"] = correlation_id
|
||||
if latency_ms is not None:
|
||||
payload["latency_ms"] = round(latency_ms, 1)
|
||||
if reason:
|
||||
payload["reason"] = reason
|
||||
self.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response=payload,
|
||||
request_data=data,
|
||||
guardrail_status=guardrail_status,
|
||||
duration=(latency_ms / 1000.0) if latency_ms is not None else None,
|
||||
guardrail_provider=self.guardrail_provider,
|
||||
event_type=GuardrailEventHooks.pre_mcp_call,
|
||||
)
|
||||
|
|
@ -6,6 +6,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|||
from typing_extensions import Required, TypedDict
|
||||
|
||||
from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import (
|
||||
Agent365GuardrailConfigModel,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.akto import (
|
||||
AktoConfigModel,
|
||||
)
|
||||
|
|
@ -134,6 +137,7 @@ class SupportedGuardrailIntegrations(Enum):
|
|||
HEADROOM = "headroom"
|
||||
COMPRESR = "compresr"
|
||||
STRAIKER = "straiker"
|
||||
AGENT_365 = "agent_365"
|
||||
|
||||
|
||||
class Role(Enum):
|
||||
|
|
@ -872,7 +876,7 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up
|
|||
default="fail_closed",
|
||||
description=(
|
||||
"Behavior when a guardrail endpoint is unreachable due to network errors. "
|
||||
"Implemented by guardrail='generic_guardrail_api', 'akto', 'vigil_guard', 'repelloai', 'headroom', and 'compresr'. "
|
||||
"Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', and 'compresr'. "
|
||||
"'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed."
|
||||
),
|
||||
)
|
||||
|
|
@ -999,6 +1003,7 @@ class LitellmParams(
|
|||
QostodianNexusConfigModel,
|
||||
VigilGuardGuardrailConfigModel,
|
||||
SingulrGuardrailConfigModel,
|
||||
Agent365GuardrailConfigModel,
|
||||
):
|
||||
guardrail: str = Field(description="The type of guardrail integration to use")
|
||||
mode: str | list[str] | Mode = Field(
|
||||
|
|
|
|||
66
litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py
Normal file
66
litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from typing import Final
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from .base import GuardrailConfigModel
|
||||
|
||||
AGENT_365_PROD_API_BASE: Final = "https://agent365.svc.cloud.microsoft"
|
||||
AGENT_365_PROD_RESOURCE_APP_ID: Final = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"
|
||||
AGENT_365_SCOPE_NAME: Final = "ThreatProtection.Evaluate.All"
|
||||
|
||||
|
||||
class Agent365GuardrailConfigModel(GuardrailConfigModel):
|
||||
tenant_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Entra tenant id used for the On-Behalf-Of token exchange. "
|
||||
"Falls back to the AGENT365_TENANT_ID environment variable."
|
||||
),
|
||||
)
|
||||
|
||||
client_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Client id of the gateway's Entra app registration (a confidential client). "
|
||||
"Falls back to the AGENT365_CLIENT_ID environment variable."
|
||||
),
|
||||
)
|
||||
|
||||
client_secret: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Client secret of the gateway's Entra app registration, used to perform the "
|
||||
"On-Behalf-Of exchange. Falls back to the AGENT365_CLIENT_SECRET environment variable."
|
||||
),
|
||||
)
|
||||
|
||||
api_base: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Base URL of the Microsoft Agent 365 tool-evaluation endpoint. "
|
||||
f"Defaults to the production endpoint {AGENT_365_PROD_API_BASE}. "
|
||||
"Falls back to the AGENT365_API_BASE environment variable."
|
||||
),
|
||||
)
|
||||
|
||||
resource_app_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Application id of the Agent 365 resource the OBO token is minted for. "
|
||||
f"Defaults to the production resource {AGENT_365_PROD_RESOURCE_APP_ID}; "
|
||||
"the Test and PreProd environments use a different id. "
|
||||
"Falls back to the AGENT365_RESOURCE_APP_ID environment variable."
|
||||
),
|
||||
)
|
||||
|
||||
agent_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Agent identity reported to Agent 365 with every tool evaluation. "
|
||||
"When unset, the caller's key alias is used."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ui_friendly_name() -> str:
|
||||
return "Microsoft Agent 365"
|
||||
|
|
@ -0,0 +1,742 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.exceptions import Timeout as LitellmTimeout
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails.guardrail_hooks.agent_365 import (
|
||||
Agent365Guardrail,
|
||||
guardrail_class_registry,
|
||||
guardrail_initializer_registry,
|
||||
initialize_guardrail,
|
||||
)
|
||||
from litellm.types.guardrails import (
|
||||
GuardrailEventHooks,
|
||||
LitellmParams,
|
||||
SupportedGuardrailIntegrations,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import (
|
||||
AGENT_365_PROD_API_BASE,
|
||||
AGENT_365_PROD_RESOURCE_APP_ID,
|
||||
Agent365GuardrailConfigModel,
|
||||
)
|
||||
|
||||
FAKE_ASSERTION: Final = "eyJhbGciOi.eyJhdWQiOi.c2lnbmF0dXJl"
|
||||
TOKEN_URL: Final = "https://login.microsoftonline.com/tenant-abc/oauth2/v2.0/token"
|
||||
EVALUATE_URL: Final = f"{AGENT_365_PROD_API_BASE}/agents/tool-evaluation/evaluate"
|
||||
|
||||
|
||||
def _response(status_code: int, payload: Any = None, text: str | None = None) -> httpx.Response:
|
||||
request: Final = httpx.Request("POST", "https://example.test")
|
||||
if payload is not None:
|
||||
return httpx.Response(status_code=status_code, json=payload, request=request)
|
||||
return httpx.Response(status_code=status_code, text=text or "", request=request)
|
||||
|
||||
|
||||
def _token_response(access_token: str = "obo-access-token", expires_in: int = 3599) -> httpx.Response:
|
||||
return _response(200, {"access_token": access_token, "expires_in": expires_in})
|
||||
|
||||
|
||||
def _allow_response(correlation_id: str = "corr-1") -> httpx.Response:
|
||||
return _response(
|
||||
200,
|
||||
{
|
||||
"allowed": True,
|
||||
"defender": {"status": "Evaluated", "verdict": "Allow", "message": None},
|
||||
"observability": {"status": "Recorded"},
|
||||
"correlationId": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _block_response(message: str = "Blocked by policy", correlation_id: str = "corr-2") -> httpx.Response:
|
||||
return _response(
|
||||
200,
|
||||
{
|
||||
"allowed": False,
|
||||
"defender": {"status": "Evaluated", "verdict": "Block", "message": message},
|
||||
"correlationId": correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FakeHandler:
|
||||
def __init__(self, items: list[Any]):
|
||||
self._items = list(items)
|
||||
self.calls: list[SimpleNamespace] = []
|
||||
|
||||
async def post(self, *, url, headers=None, data=None, json=None, timeout=None):
|
||||
self.calls.append(SimpleNamespace(url=url, headers=headers, data=data, json=json, timeout=timeout))
|
||||
if not self._items:
|
||||
raise AssertionError("FakeHandler ran out of programmed responses")
|
||||
item = self._items.pop(0)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
if item.status_code >= 400:
|
||||
raise httpx.HTTPStatusError("error status", request=item.request, response=item)
|
||||
return item
|
||||
|
||||
|
||||
def _make_guardrail(
|
||||
handler: FakeHandler,
|
||||
*,
|
||||
unreachable_fallback: str = "fail_closed",
|
||||
agent_id: str | None = None,
|
||||
api_base: str = AGENT_365_PROD_API_BASE,
|
||||
) -> Agent365Guardrail:
|
||||
return Agent365Guardrail(
|
||||
guardrail_name="agent-365-guard",
|
||||
tenant_id="tenant-abc",
|
||||
client_id="client-xyz",
|
||||
client_secret="secret-123",
|
||||
api_base=api_base,
|
||||
agent_id=agent_id,
|
||||
unreachable_fallback=unreachable_fallback,
|
||||
async_handler=handler,
|
||||
event_hook="pre_mcp_call",
|
||||
default_on=True,
|
||||
)
|
||||
|
||||
|
||||
def _mcp_data(**overrides: Any) -> dict:
|
||||
data: Final[dict] = {
|
||||
"mcp_tool_name": "send_email",
|
||||
"mcp_arguments": {"to": "user@example.com", "body": "hello"},
|
||||
"mcp_server_name": "outlook_mcp",
|
||||
"incoming_bearer_token": FAKE_ASSERTION,
|
||||
"metadata": {"headers": {"mcp-session-id": "sess-123"}},
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def _user() -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(api_key="hashed-key", key_alias="my-agent-key")
|
||||
|
||||
|
||||
async def _run(guardrail: Agent365Guardrail, data: dict, call_type: str = "call_mcp_tool"):
|
||||
return await guardrail.async_pre_call_hook(
|
||||
user_api_key_dict=_user(),
|
||||
cache=None,
|
||||
data=data,
|
||||
call_type=call_type,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistryWiring:
|
||||
def test_enum_member_exists(self):
|
||||
assert SupportedGuardrailIntegrations.AGENT_365.value == "agent_365"
|
||||
|
||||
def test_initializer_registry(self):
|
||||
assert guardrail_initializer_registry["agent_365"] is initialize_guardrail
|
||||
|
||||
def test_class_registry(self):
|
||||
assert guardrail_class_registry["agent_365"] is Agent365Guardrail
|
||||
|
||||
def test_config_model_wired(self):
|
||||
assert Agent365Guardrail.get_config_model() is Agent365GuardrailConfigModel
|
||||
assert Agent365GuardrailConfigModel.ui_friendly_name() == "Microsoft Agent 365"
|
||||
|
||||
def test_supported_event_hooks(self):
|
||||
assert Agent365Guardrail.get_supported_event_hooks() == [GuardrailEventHooks.pre_mcp_call]
|
||||
|
||||
|
||||
class TestInitializeGuardrail:
|
||||
def test_requires_tenant_id(self, monkeypatch):
|
||||
monkeypatch.delenv("AGENT365_TENANT_ID", raising=False)
|
||||
params: Final = LitellmParams(
|
||||
guardrail="agent_365",
|
||||
mode="pre_mcp_call",
|
||||
client_id="client-xyz",
|
||||
api_key="secret-123",
|
||||
)
|
||||
with pytest.raises(ValueError, match="tenant_id is required"):
|
||||
initialize_guardrail(params, {"guardrail_name": "a365"})
|
||||
|
||||
def test_requires_client_secret(self, monkeypatch):
|
||||
monkeypatch.delenv("AGENT365_CLIENT_SECRET", raising=False)
|
||||
params: Final = LitellmParams(
|
||||
guardrail="agent_365",
|
||||
mode="pre_mcp_call",
|
||||
tenant_id="tenant-abc",
|
||||
client_id="client-xyz",
|
||||
)
|
||||
with pytest.raises(ValueError, match="client_secret"):
|
||||
initialize_guardrail(params, {"guardrail_name": "a365"})
|
||||
|
||||
def test_env_var_fallbacks(self, monkeypatch):
|
||||
monkeypatch.delenv("AGENT365_RESOURCE_APP_ID", raising=False)
|
||||
monkeypatch.setenv("AGENT365_TENANT_ID", "env-tenant")
|
||||
monkeypatch.setenv("AGENT365_CLIENT_ID", "env-client")
|
||||
monkeypatch.setenv("AGENT365_CLIENT_SECRET", "env-secret")
|
||||
monkeypatch.setenv("AGENT365_API_BASE", "https://env.example.test")
|
||||
params: Final = LitellmParams(guardrail="agent_365", mode="pre_mcp_call")
|
||||
guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-env"})
|
||||
assert guardrail.tenant_id == "env-tenant"
|
||||
assert guardrail.client_id == "env-client"
|
||||
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_closed"
|
||||
|
||||
def test_explicit_params_win(self, monkeypatch):
|
||||
monkeypatch.setenv("AGENT365_TENANT_ID", "env-tenant")
|
||||
params: Final = LitellmParams(
|
||||
guardrail="agent_365",
|
||||
mode="pre_mcp_call",
|
||||
tenant_id="param-tenant",
|
||||
client_id="client-xyz",
|
||||
client_secret="param-secret",
|
||||
agent_id="agent-007",
|
||||
unreachable_fallback="fail_open",
|
||||
timeout=5,
|
||||
)
|
||||
guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-params"})
|
||||
assert guardrail.tenant_id == "param-tenant"
|
||||
assert guardrail.client_secret == "param-secret"
|
||||
assert guardrail.agent_id == "agent-007"
|
||||
assert guardrail.unreachable_fallback == "fail_open"
|
||||
assert guardrail.request_timeout == 5.0
|
||||
|
||||
def test_wrong_mode_rejected(self):
|
||||
params: Final = LitellmParams(
|
||||
guardrail="agent_365",
|
||||
mode="post_call",
|
||||
tenant_id="tenant-abc",
|
||||
client_id="client-xyz",
|
||||
api_key="secret-123",
|
||||
)
|
||||
with pytest.raises(Exception, match="post_call"):
|
||||
initialize_guardrail(params, {"guardrail_name": "a365-badmode"})
|
||||
|
||||
|
||||
def _guardrail_info(data: dict) -> dict:
|
||||
entries: Final = data["metadata"]["standard_logging_guardrail_information"]
|
||||
return entries[-1]
|
||||
|
||||
|
||||
class TestAllowFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowed_call_passes_through(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "success"
|
||||
assert info["guardrail_provider"] == "agent_365"
|
||||
assert info["guardrail_response"]["verdict"] == "Allow"
|
||||
assert info["guardrail_response"]["defender_status"] == "Evaluated"
|
||||
assert info["guardrail_response"]["correlation_id"] == "corr-1"
|
||||
assert info["guardrail_response"]["latency_ms"] >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_exchange_form(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
token_call: Final = handler.calls[0]
|
||||
assert token_call.url == TOKEN_URL
|
||||
assert token_call.data["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
assert token_call.data["requested_token_use"] == "on_behalf_of"
|
||||
assert token_call.data["assertion"] == FAKE_ASSERTION
|
||||
assert token_call.data["client_id"] == "client-xyz"
|
||||
assert token_call.data["client_secret"] == "secret-123"
|
||||
assert token_call.data["scope"] == f"{AGENT_365_PROD_RESOURCE_APP_ID}/ThreatProtection.Evaluate.All"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_payload(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler, agent_id="agent-007")
|
||||
await _run(guardrail, _mcp_data())
|
||||
evaluate_call: Final = handler.calls[1]
|
||||
assert evaluate_call.url == EVALUATE_URL
|
||||
assert evaluate_call.headers["Authorization"] == "Bearer obo-access-token"
|
||||
assert evaluate_call.json["tool"] == {"name": "send_email"}
|
||||
assert evaluate_call.json["serverName"] == "outlook_mcp"
|
||||
assert evaluate_call.json["arguments"] == {"to": "user@example.com", "body": "hello"}
|
||||
assert evaluate_call.json["conversationId"] == "sess-123"
|
||||
assert evaluate_call.json["agentId"] == "agent-007"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_id_falls_back_to_key_alias(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert handler.calls[1].json["agentId"] == "my-agent-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_mcp_call_type_skipped(self):
|
||||
handler: Final = FakeHandler([])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data, call_type="completion")
|
||||
assert result is data
|
||||
assert handler.calls == []
|
||||
|
||||
|
||||
class TestConversationId:
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_id_header_case_insensitive(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data(metadata={"headers": {"Mcp-Session-Id": "sess-CASED"}})
|
||||
await _run(guardrail, data)
|
||||
assert handler.calls[1].json["conversationId"] == "sess-CASED"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_logging_obj_session_id(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
logging_obj: Final = SimpleNamespace(
|
||||
model_call_details={"mcp_tool_call_metadata": {"mcp_session_id": "sess-from-logging"}},
|
||||
litellm_call_id="call-id-1",
|
||||
)
|
||||
data: Final = _mcp_data(metadata={"headers": {}}, litellm_logging_obj=logging_obj)
|
||||
await _run(guardrail, data)
|
||||
assert handler.calls[1].json["conversationId"] == "sess-from-logging"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_litellm_call_id(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data(metadata={"headers": {}}, litellm_call_id="call-id-2")
|
||||
await _run(guardrail, data)
|
||||
assert handler.calls[1].json["conversationId"] == "call-id-2"
|
||||
|
||||
|
||||
class TestBlockFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_call_raises_400(self):
|
||||
handler: Final = FakeHandler([_token_response(), _block_response(message="Injection detected")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail["error"] == "Blocked by Microsoft Defender"
|
||||
assert exc_info.value.detail["message"] == "Injection detected"
|
||||
assert exc_info.value.detail["tool"] == "send_email"
|
||||
assert exc_info.value.detail["correlation_id"] == "corr-2"
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_intervened"
|
||||
assert info["guardrail_response"]["verdict"] == "Block"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_even_with_fail_open(self):
|
||||
handler: Final = FakeHandler([_token_response(), _block_response()])
|
||||
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
|
||||
async def test_http_400_always_blocks_even_fail_open(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(400, text="Bad request: serverName missing")])
|
||||
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
|
||||
assert "rejected" in exc_info.value.detail["error"]
|
||||
|
||||
|
||||
class TestUnreachableFallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_litellm_timeout_fail_closed(self):
|
||||
handler: Final = FakeHandler(
|
||||
[
|
||||
_token_response(),
|
||||
LitellmTimeout(message="Connection timed out", model="default-model-name", llm_provider="httpx"),
|
||||
]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_timeout_fail_closed(self):
|
||||
handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "fail_closed" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_timeout_fail_open(self):
|
||||
handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result 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_evaluate_5xx_fail_closed(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(502, text="bad gateway")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "502" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_bearer_token_fail_closed(self):
|
||||
handler: Final = FakeHandler([])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data(incoming_bearer_token=None))
|
||||
assert exc_info.value.status_code == 401
|
||||
assert handler.calls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_jwt_bearer_token_fail_closed(self):
|
||||
handler: Final = FakeHandler([])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data(incoming_bearer_token="sk-litellm-virtual-key"))
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_bearer_token_blocks_even_fail_open(self):
|
||||
handler: Final = FakeHandler([])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data(incoming_bearer_token=None)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert handler.calls == []
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_intervened"
|
||||
assert info["guardrail_response"]["verdict"] == "Rejected"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_rejected_blocks_even_fail_open(self):
|
||||
handler: Final = FakeHandler(
|
||||
[_response(400, {"error": "invalid_grant", "error_description": "AADSTS50013: bad assertion"})]
|
||||
)
|
||||
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 == 401
|
||||
assert "invalid_grant" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_4xx_blocks_even_fail_open(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(403, text="obo token lacks the scope")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "403" in exc_info.value.detail["message"]
|
||||
assert "lacks the scope" not in exc_info.value.detail["message"]
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_intervened"
|
||||
assert info["guardrail_response"]["reason"] == "HTTP 403: obo token lacks the scope"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_rejected_fail_closed(self):
|
||||
handler: Final = FakeHandler(
|
||||
[_response(400, {"error": "invalid_grant", "error_description": "AADSTS50013: bad assertion"})]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "invalid_grant" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_endpoint_5xx_fail_open(self):
|
||||
handler: Final = FakeHandler([_response(503, text="entra down")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert info["guardrail_response"]["verdict"] == "Unscanned"
|
||||
|
||||
|
||||
class TestOboTokenCache:
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_assertion_reuses_token(self):
|
||||
handler: Final = FakeHandler([_token_response(), _allow_response(), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
await _run(guardrail, _mcp_data())
|
||||
token_calls: Final = [c for c in handler.calls if c.url == TOKEN_URL]
|
||||
assert len(token_calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_assertions_get_distinct_tokens(self):
|
||||
other_assertion: Final = "eyJhbGciOi.eyJvdGhlciI.b3RoZXJzaWc"
|
||||
handler: Final = FakeHandler(
|
||||
[
|
||||
_token_response(access_token="token-a"),
|
||||
_allow_response(),
|
||||
_token_response(access_token="token-b"),
|
||||
_allow_response(),
|
||||
]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
await _run(guardrail, _mcp_data(incoming_bearer_token=other_assertion))
|
||||
token_calls: Final = [c for c in handler.calls if c.url == TOKEN_URL]
|
||||
assert len(token_calls) == 2
|
||||
assert handler.calls[3].headers["Authorization"] == "Bearer token-b"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_refreshed(self):
|
||||
handler: Final = FakeHandler(
|
||||
[
|
||||
_token_response(access_token="short-lived", expires_in=1),
|
||||
_allow_response(),
|
||||
_token_response(access_token="fresh"),
|
||||
_allow_response(),
|
||||
]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
await _run(guardrail, _mcp_data())
|
||||
token_calls: Final = [c for c in handler.calls if c.url == TOKEN_URL]
|
||||
assert len(token_calls) == 2
|
||||
assert handler.calls[3].headers["Authorization"] == "Bearer fresh"
|
||||
|
||||
|
||||
class TestEarlyPhasePassthrough:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_body_shape_without_mcp_fields_skipped(self):
|
||||
handler: Final = FakeHandler([])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = {
|
||||
"server_id": "266024044f9612bf481c78f6cfef1ff0",
|
||||
"name": "deepwiki-read_wiki_structure",
|
||||
"arguments": {"repoName": "BerriAI/litellm"},
|
||||
"metadata": {"headers": {"mcp-session-id": "sess-123"}},
|
||||
}
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
assert handler.calls == []
|
||||
assert "standard_logging_guardrail_information" not in data["metadata"]
|
||||
|
||||
|
||||
class TestRegistryDiscovery:
|
||||
def test_auto_discovery_finds_agent_365(self):
|
||||
from litellm.proxy.guardrails.guardrail_registry import (
|
||||
get_guardrail_class_from_hooks,
|
||||
get_guardrail_initializer_from_hooks,
|
||||
)
|
||||
|
||||
assert "agent_365" in get_guardrail_initializer_from_hooks()
|
||||
assert get_guardrail_class_from_hooks()["agent_365"] is Agent365Guardrail
|
||||
|
||||
|
||||
class TestMalformedResponses:
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_html_body_fail_open(self):
|
||||
handler: Final = FakeHandler([_response(200, text="<html>blocked by egress proxy</html>")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
assert _guardrail_info(data)["guardrail_response"]["verdict"] == "Unscanned"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_html_body_fail_closed(self):
|
||||
handler: Final = FakeHandler([_response(200, text="<html>outage</html>")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "non-JSON" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_non_object_json_fail_closed(self):
|
||||
handler: Final = FakeHandler([_response(200, ["not", "a", "dict"])])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_html_body_fail_open(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(200, text="<html>waf page</html>")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
assert _guardrail_info(data)["guardrail_response"]["verdict"] == "Unscanned"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_html_body_fail_closed(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(200, text="<html>waf page</html>")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_non_object_json_fail_closed(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(200, "allowed")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_expires_in_still_allows(self):
|
||||
handler: Final = FakeHandler([_response(200, {"access_token": "tok-1", "expires_in": "soon"}), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obo_litellm_timeout_fail_open(self):
|
||||
handler: Final = FakeHandler(
|
||||
[LitellmTimeout(message="Connection timed out", model="default-model-name", llm_provider="httpx")]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
assert _guardrail_info(data)["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
|
||||
|
||||
class TestDeltaHardening:
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_access_token_fail_closed(self):
|
||||
handler: Final = FakeHandler([_response(200, {"access_token": None, "expires_in": 3599})])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "access_token" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_numeric_string_expires_in_honored(self):
|
||||
handler: Final = FakeHandler([_response(200, {"access_token": "tok-9", "expires_in": "120"}), _allow_response()])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
await _run(guardrail, _mcp_data())
|
||||
entries: Final = list(guardrail._obo_token_cache.values())
|
||||
assert len(entries) == 1
|
||||
assert entries[0][1] - time.time() < 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_400_records_intervention(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(400, text="bad request shape")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 400
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_intervened"
|
||||
assert info["guardrail_response"]["verdict"] == "Rejected"
|
||||
|
||||
|
||||
class TestVeriaHardening:
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_401_evicts_cached_obo_token(self):
|
||||
handler: Final = FakeHandler(
|
||||
[
|
||||
_token_response(),
|
||||
_response(401, text="token expired"),
|
||||
_token_response(access_token="tok-2"),
|
||||
_allow_response(),
|
||||
]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException):
|
||||
await _run(guardrail, _mcp_data())
|
||||
result: Final = await _run(guardrail, _mcp_data())
|
||||
assert result is not None
|
||||
token_calls: Final = [c for c in handler.calls if c.url == TOKEN_URL]
|
||||
assert len(token_calls) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_429_blocks_even_fail_open_as_throttled(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(429, text="slow down")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "429" in exc_info.value.detail["message"]
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert info["guardrail_response"]["verdict"] == "Throttled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_500_is_unavailable(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(500, text="oops")])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "500" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_429_blocks_even_fail_open_as_throttled(self):
|
||||
handler: Final = FakeHandler(
|
||||
[_response(429, {"error": "temporarily_throttled", "error_description": "AADSTS90056"})]
|
||||
)
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "429" in exc_info.value.detail["message"]
|
||||
info: Final = _guardrail_info(data)
|
||||
assert info["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
assert info["guardrail_response"]["verdict"] == "Throttled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_408_non_json_blocks_as_throttled(self):
|
||||
handler: Final = FakeHandler([_response(408, text="Request Timeout")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, data)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert _guardrail_info(data)["guardrail_response"]["verdict"] == "Throttled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_endpoint_4xx_html_stays_infra_fail_open(self):
|
||||
handler: Final = FakeHandler([_response(403, text="<html>waf block page</html>")])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
assert _guardrail_info(data)["guardrail_response"]["verdict"] == "Unscanned"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entra_200_missing_access_token_is_malformed(self):
|
||||
handler: Final = FakeHandler([_response(200, {"token_type": "Bearer"})])
|
||||
guardrail: Final = _make_guardrail(handler)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _run(guardrail, _mcp_data())
|
||||
assert exc_info.value.status_code == 503
|
||||
assert "access_token" in exc_info.value.detail["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_5xx_fail_open_allows_unscanned_once(self):
|
||||
handler: Final = FakeHandler([_token_response(), _response(502, text='{"error": "bad gateway"}')])
|
||||
guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open")
|
||||
data: Final = _mcp_data()
|
||||
result: Final = await _run(guardrail, data)
|
||||
assert result is data
|
||||
records: Final = data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(records) == 1
|
||||
assert records[0]["guardrail_response"]["verdict"] == "Unscanned"
|
||||
assert records[0]["guardrail_status"] == "guardrail_failed_to_respond"
|
||||
|
|
@ -312,4 +312,11 @@ export const GUARDRAIL_PRESETS: Record<string, GuardrailPreset> = {
|
|||
mode: "pre_call",
|
||||
defaultOn: false,
|
||||
},
|
||||
agent_365: {
|
||||
provider: "Agent365",
|
||||
guardrailNameSuggestion: "Microsoft Agent 365 Guardrail",
|
||||
mode: "pre_mcp_call",
|
||||
// MCP-only: default_on is the only activation path on the MCP hook
|
||||
defaultOn: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record<string, string> = {
|
|||
deepkeep: "deepkeep.svg",
|
||||
repelloai: "repelloai.png",
|
||||
straiker: "straiker.svg",
|
||||
agent_365: "microsoft_azure.svg",
|
||||
};
|
||||
|
||||
describe("guardrail_garden_data logos", () => {
|
||||
|
|
|
|||
|
|
@ -464,6 +464,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
|
|||
tags: ["Agentic", "Prompt Injection", "Tool Misuse", "MCP", "Skills"],
|
||||
providerKey: "Straiker",
|
||||
},
|
||||
{
|
||||
id: "agent_365",
|
||||
name: "Microsoft Agent 365",
|
||||
description:
|
||||
"Microsoft Agent 365 tool-call governance: Defender threat evaluation and observability for MCP tool calls, acting on behalf of the signed-in user",
|
||||
category: "partner",
|
||||
logo: guardrailLogoMap["Microsoft Agent 365"],
|
||||
tags: ["Agentic", "MCP", "Tool Misuse", "Observability"],
|
||||
providerKey: "Agent365",
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_CARDS = [...LITELLM_CONTENT_FILTER_CARDS, ...PARTNER_GUARDRAIL_CARDS];
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ export const guardrailLogoMap = {
|
|||
"Qostodian Nexus": qohashLogo.src,
|
||||
"RepelloAI Argus": repelloAiLogo.src,
|
||||
Straiker: straikerLogo.src,
|
||||
"Microsoft Agent 365": microsoftAzureLogo.src,
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
export const getGuardrailLogo = (displayName: string): string | undefined =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue