Merge pull request #38241 from BerriAI/litellm_agent365_mcp_guardrail

feat(guardrails): add Microsoft Agent 365 MCP tool-call guardrail
This commit is contained in:
yucheng-berri 2026-09-15 18:21:46 -07:00 committed by GitHub
commit a9a654cb60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1888 additions and 3 deletions

View file

@ -9986,7 +9986,7 @@
},
"unreachable_fallback": {
"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'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.",
"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', and 'compresr'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.",
"enum": [
"fail_closed",
"fail_open"
@ -10948,6 +10948,18 @@
"description": "Custom advisory message template used when on_flagged='inject_system_message'. Must contain a {reason} placeholder. Defaults to a generic advisory message if unset.",
"title": "Advisory System Message"
},
"agent_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Agent identity reported to Agent 365 with every tool evaluation. When unset, the caller's key alias is used.",
"title": "Agent Id"
},
"akto_account_id": {
"anyOf": [
{
@ -11450,6 +11462,30 @@
"title": "Chunk Budget Chars",
"type": "integer"
},
"client_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Client id of the gateway's Entra app registration (a confidential client). Falls back to the AGENT365_CLIENT_ID environment variable.",
"title": "Client Id"
},
"client_secret": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"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.",
"title": "Client Secret"
},
"confidence_threshold": {
"default": 0.5,
"default_value": 0.5,
@ -12496,6 +12532,18 @@
"description": "The message the bot speaks aloud when a /v1/realtime guardrail fires. Falls back to violation_message_template if not set.",
"title": "Realtime Violation Message"
},
"resource_app_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Application id of the Agent 365 resource the OBO token is minted for. Defaults to the production resource ea9ffc3e-8a23-4a7d-836d-234d7c7565c1; the Test and PreProd environments use a different id. Falls back to the AGENT365_RESOURCE_APP_ID environment variable.",
"title": "Resource App Id"
},
"rules": {
"anyOf": [
{
@ -12733,6 +12781,18 @@
"description": "The ID of your Model Armor template",
"title": "Template Id"
},
"tenant_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Entra tenant id used for the On-Behalf-Of token exchange. Falls back to the AGENT365_TENANT_ID environment variable.",
"title": "Tenant Id"
},
"timeout": {
"anyOf": [
{

View file

@ -0,0 +1,63 @@
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 is required. Set client_secret, api_key, or AGENT365_CLIENT_SECRET"
)
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,
}

View file

@ -0,0 +1,637 @@
"""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 pydantic import TypeAdapter, ValidationError
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.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
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"
DEFENDER_STATUS_EVALUATED: Final = "Evaluated"
_GATEWAY_OWNED_TOKEN_ERRORS: Final = frozenset(
{"invalid_client", "unauthorized_client", "invalid_scope", "invalid_resource"}
)
# Entra reports a malformed or unverifiable assertion as ``invalid_client`` too; only its AADSTS50027xx
# (InvalidJwtToken) sub-codes tell that apart from a bad gateway secret.
_INVALID_ASSERTION_AADSTS_PREFIX: Final = "50027"
_AADSTS_CODES_ADAPTER: Final = TypeAdapter(tuple[int, ...])
_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
def _parse_aadsts_codes(raw: object) -> tuple[int, ...]:
try:
return _AADSTS_CODES_ADAPTER.validate_python(raw)
except ValidationError:
return ()
def entra_assertion(value: object) -> str | None:
"""``value`` when it is a compact JWS, the only bearer shape the OBO exchange accepts as its assertion.
A LiteLLM virtual key, session bearer, or opaque upstream token in ``Authorization`` yields ``None``."""
return value if isinstance(value, str) and value.count(".") == 2 else None
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 _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, aadsts_codes: tuple[int, ...] = ()) -> None:
super().__init__(f"{error_code}: {description}")
self.status_code = status_code
self.error_code = error_code
self.description = description
self.aadsts_codes = aadsts_codes
@property
def gateway_owned(self) -> bool:
"""Whether the gateway's own client credentials, scope or resource were refused, as opposed to the
caller's assertion. The caller cannot fix a gateway-owned rejection by signing in again."""
if self.error_code not in _GATEWAY_OWNED_TOKEN_ERRORS:
return False
return not any(str(code).startswith(_INVALID_ASSERTION_AADSTS_PREFIX) for code in self.aadsts_codes)
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.
Block-only: it never rewrites the call, so it runs in the post-sequential phase and judges the
arguments the sequential guardrails hand upstream, whatever order the guardrails list uses."""
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(),
run_in_parallel=True,
**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 = entra_assertion(data.get("incoming_bearer_token"))
if assertion is None:
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:
if exc.gateway_owned:
return self._handle_unavailable(
data=data,
tool_name=tool_name,
reason=(
f"Entra rejected the gateway's own Agent 365 credentials ({exc.error_code}); "
"check the guardrail's client_id, client_secret and resource_app_id"
),
)
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
allowed: Final = verdict.get("allowed")
if not isinstance(allowed, bool):
return self._handle_unavailable(
data=data,
tool_name=tool_name,
reason="the Agent 365 endpoint returned a verdict without a boolean 'allowed' field",
)
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
defender_status: Final = defender.get("status")
if allowed and defender_status != DEFENDER_STATUS_EVALUATED:
return self._handle_unavailable(
data=data,
tool_name=tool_name,
reason=f"Microsoft Defender did not evaluate the call (defender.status={defender_status or 'missing'})",
defender_status=defender_status,
correlation_id=correlation_id,
latency_ms=latency_ms,
)
self._record_verdict(
data=data,
verdict="Allow" if allowed else "Block",
guardrail_status="success" if allowed else "guardrail_intervened",
defender_status=defender_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 user_api_key_dict.key_alias
payload: Final[dict[str, object]] = { # mutable-ok: JSON body with optional fields added below
"tool": {"name": tool_name},
"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:
"""The MCP session groups every tool call of one client conversation, so it is the conversation id
when the transport carries one; stateless calls fall back to the per-call id."""
raw_logging_obj: Final = data.get("litellm_logging_obj")
logging_obj: Final = raw_logging_obj if isinstance(raw_logging_obj, LiteLLMLoggingObj) else None
if logging_obj is not None:
tool_call_metadata: Final = logging_obj.model_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
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
call_id: Final = data.get("litellm_call_id") or (logging_obj.litellm_call_id if logging_obj else 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],
aadsts_codes=_parse_aadsts_codes(body.get("error_codes")),
)
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,
defender_status: str | None = None,
correlation_id: str | None = None,
latency_ms: float | None = None,
) -> 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=defender_status,
correlation_id=correlation_id,
latency_ms=latency_ms,
reason=reason,
)
return data
self._record_verdict(
data=data,
verdict="Unavailable",
guardrail_status="guardrail_failed_to_respond",
defender_status=defender_status,
correlation_id=correlation_id,
latency_ms=latency_ms,
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,
)

View file

@ -8,6 +8,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,
)
@ -137,6 +140,7 @@ class SupportedGuardrailIntegrations(Enum):
COMPRESR = "compresr"
STRAIKER = "straiker"
ALICE = "alice"
AGENT_365 = "agent_365"
CONDUCT = "conduct"
@ -1045,7 +1049,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."
),
)
@ -1183,6 +1187,7 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o
QostodianNexusConfigModel,
VigilGuardGuardrailConfigModel,
SingulrGuardrailConfigModel,
Agent365GuardrailConfigModel,
):
guardrail: str = Field(description="The type of guardrail integration to use")
mode: str | list[str] | Mode = Field(

View 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"

File diff suppressed because it is too large Load diff

View file

@ -318,6 +318,13 @@ 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,
},
conduct: {
provider: "Conduct",
guardrailNameSuggestion: "Conduct Guard",

View file

@ -28,6 +28,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record<string, string> = {
repelloai: "repelloai.png",
straiker: "straiker.svg",
alice: "alice.svg",
agent_365: "microsoft_azure.svg",
conduct: "conduct.png",
};

View file

@ -474,6 +474,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [
tags: ["Content Moderation", "Prompt Injection", "PII", "Policy"],
providerKey: "Alice",
},
{
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",
},
{
id: "conduct",
name: "Conduct Guard",

View file

@ -210,6 +210,7 @@ export const guardrailLogoMap = {
"RepelloAI Argus": repelloAiLogo.src,
Straiker: straikerLogo.src,
Alice: aliceLogo.src,
"Microsoft Agent 365": microsoftAzureLogo.src,
"Conduct Guard": conductLogo.src,
} satisfies Record<string, string>;

View file

@ -24190,7 +24190,7 @@ 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', '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.
* @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', and 'compresr'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.
* @default fail_closed
* @enum {string}
*/
@ -31281,6 +31281,11 @@ export interface components {
* @description Custom advisory message template used when on_flagged='inject_system_message'. Must contain a {reason} placeholder. Defaults to a generic advisory message if unset.
*/
advisory_system_message?: string | null;
/**
* Agent Id
* @description Agent identity reported to Agent 365 with every tool evaluation. When unset, the caller's key alias is used.
*/
agent_id?: string | null;
/**
* Akto Account Id
* @description Akto account ID for multi-tenant deployments. Env: AKTO_ACCOUNT_ID. Default: '1000000'.
@ -31482,6 +31487,16 @@ export interface components {
* @default 25000
*/
chunk_budget_chars: number;
/**
* Client Id
* @description Client id of the gateway's Entra app registration (a confidential client). Falls back to the AGENT365_CLIENT_ID environment variable.
*/
client_id?: string | null;
/**
* Client Secret
* @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.
*/
client_secret?: string | null;
/**
* Confidence Threshold
* @description Only block or mask when detection confidence >= this value; below threshold, allow or log_only.
@ -31921,6 +31936,11 @@ export interface components {
* @description The message the bot speaks aloud when a /v1/realtime guardrail fires. Falls back to violation_message_template if not set.
*/
realtime_violation_message?: string | null;
/**
* Resource App Id
* @description Application id of the Agent 365 resource the OBO token is minted for. Defaults to the production resource ea9ffc3e-8a23-4a7d-836d-234d7c7565c1; the Test and PreProd environments use a different id. Falls back to the AGENT365_RESOURCE_APP_ID environment variable.
*/
resource_app_id?: string | null;
/**
* Rules
* @description Ordered allow/deny rules. Patterns use regex for tool names/types and optional regex constraints on tool arguments.
@ -32022,6 +32042,11 @@ export interface components {
* @description The ID of your Model Armor template
*/
template_id?: string | null;
/**
* Tenant Id
* @description Entra tenant id used for the On-Behalf-Of token exchange. Falls back to the AGENT365_TENANT_ID environment variable.
*/
tenant_id?: string | null;
/**
* Timeout
* @description Per-request timeout for the guardrail provider API call (seconds). Accepts int, float, or numeric string; coerced to float on load. Each guardrail handler chooses its own default when unset.