This commit is contained in:
medophin 2026-08-26 21:00:58 -04:00 committed by GitHub
commit 48e93e0eda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1820 additions and 12 deletions

View file

@ -2320,9 +2320,18 @@ async def apply_guardrail(
if litellm_logging_obj is not None:
_patch_logging_obj_for_guardrail(litellm_logging_obj, request)
# The proxy-injected metadata is merged last so it wins: the body's is
# caller-controlled, and a guardrail reading it would let a caller name a
# different virtual key than the one that authenticated.
merged_metadata: Final = {
name: value
for source in (request.metadata, data.get("metadata"))
if isinstance(source, dict)
for name, value in source.items()
}
request_data: Final[dict] = {
**({"messages": request.messages} if request.messages is not None else {}),
**({"metadata": request.metadata} if request.metadata is not None else {}),
"metadata": merged_metadata,
}
_input_type: Final = _resolve_guardrail_input_type(active_guardrail, request.input_type)
guardrailed_inputs: Final = await active_guardrail.apply_guardrail(

View file

@ -19,6 +19,11 @@ def initialize_guardrail(
api_key=litellm_params.api_key,
xecguard_model=litellm_params.xecguard_model,
policy_names=litellm_params.policy_names,
apply_to_aliases=litellm_params.apply_to_aliases,
except_aliases=litellm_params.except_aliases,
send_meta=litellm_params.send_meta,
meta_data_fields=litellm_params.meta_data_fields,
meta_identity_format=litellm_params.meta_identity_format,
block_on_error=litellm_params.block_on_error,
grounding_strictness=litellm_params.grounding_strictness,
guardrail_name=guardrail.get(

View file

@ -20,10 +20,18 @@ Design notes (intentional divergences from the framework defaults):
directly for ``logging_only`` mode - it does NOT bridge to
``apply_guardrail``. Our override runs the scan non-blockingly and
swallows every exception.
* When ``send_meta`` is enabled the scan payload carries a ``meta``
object identifying the calling virtual key. It is correlation data
for XecGuard's SIEM export only and never affects the verdict; the
backend's flat-scalar contract for it is enforced client-side so a
malformed key metadata entry cannot fail an otherwise valid scan.
"""
import asyncio
import json
import os
import re
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, Literal, Optional
@ -40,6 +48,10 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._types import (
LiteLLM_ManagementEndpoint_MetadataFields,
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import (
GenericGuardrailAPIInputs,
@ -76,6 +88,76 @@ _DEFAULT_POLICIES: Final = [
"Default_Policy_GeneralPromptAttackProtection",
]
# ``meta`` contract of POST /xecguard/v1/scan: an optional object carrying caller
# context that takes no part in detection. XecGuard flattens it into the SIEM
# event (``virtualkey`` -> ``ctx_virtualkey``, ``data.X`` -> ``ctx_X``), and SIEM
# index fields only accept flat scalars - anything else is rejected with 400. So
# every value is coerced or dropped here rather than risking a scan failure.
_METADATA_KEY_METADATA_FIELD: Final = "user_api_key_metadata"
_META_NAME_PATTERN: Final = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$")
_META_CONTROL_CHARS: Final = re.compile(r"[\x00-\x1f\x7f]")
_META_MAX_DATA_FIELDS: Final = 32
_META_MAX_VALUE_CHARS: Final = 512
_META_MAX_SERIALIZED_BYTES: Final = 4096
# Never forwarded, and ``meta_data_fields`` cannot opt them back in: these slots
# hold credentials, so there is no configuration under which shipping them to an
# external SIEM is right.
_META_EXCLUDED_DATA_FIELDS: Final = frozenset({"logging", "callback_settings", "secret_manager_settings"})
# The proxy stores its own per-key control settings inside key metadata - rate
# limits, budget knobs, enforced params, ``disable_global_guardrails``. They sit
# in the same dict as the admin's own fields but they are proxy configuration,
# not caller identity: noise in a SIEM, they eat the 32-field / 4096-byte budget,
# and a couple of them describe the key's security posture. Skipped by default,
# but an admin who explicitly names one in ``meta_data_fields`` gets it - unlike
# the credential slots above, forwarding these is a judgement call, not a bug.
#
# Taken from the proxy's own lists rather than copied, so a field litellm adds
# later is covered without an edit here.
_META_CONTROL_DATA_FIELDS: Final = (
frozenset(LiteLLM_ManagementEndpoint_MetadataFields) | frozenset(LiteLLM_ManagementEndpoint_MetadataFields_Premium)
) - _META_EXCLUDED_DATA_FIELDS
# Two shapes for ``meta.virtualkey``. "string" is the identity as a bare string,
# which is all the currently deployed backend accepts. "object" carries the alias
# and the key id side by side, so a SIEM event is attributable even when the alias
# is absent, renamed, or reused - it needs a backend that validates the object
# form, hence the switch rather than a straight cutover.
_META_IDENTITY_FORMATS: Final = ("string", "object")
_DEFAULT_META_IDENTITY_FORMAT: Final = "string"
# Virtual-key attributes the proxy injects alongside every request, forwarded as
# ``meta.data`` so a SIEM event can be attributed without a lookup back into the
# proxy database. Ordered: identity first, then tenancy, then commercials, so the
# fields that survive the 32-field / 4096-byte caps are the ones worth keeping.
#
# This set deliberately includes PII (``user_email``) and commercial figures
# (``spend``, ``max_budget``). Both leave the proxy only when ``send_meta`` is
# explicitly enabled, and ``meta_data_fields`` narrows the set for deployments
# that must not egress them.
_META_AUTO_DATA_FIELDS: Final[tuple[tuple[str, str], ...]] = (
("key_id", "user_api_key_hash"),
("key_alias", "user_api_key_alias"),
("team_id", "user_api_key_team_id"),
("team_alias", "user_api_key_team_alias"),
("user_id", "user_api_key_user_id"),
("user_email", "user_api_key_user_email"),
("org_id", "user_api_key_org_id"),
("org_alias", "user_api_key_org_alias"),
("project_id", "user_api_key_project_id"),
("project_alias", "user_api_key_project_alias"),
("end_user_id", "user_api_key_end_user_id"),
("spend", "user_api_key_spend"),
("max_budget", "user_api_key_max_budget"),
("request_route", "user_api_key_request_route"),
)
def _sanitized_meta_text(text: str) -> str | None:
"""Strip control characters and cap the length, or None when nothing is left."""
cleaned: Final = _META_CONTROL_CHARS.sub("", text)[:_META_MAX_VALUE_CHARS]
return cleaned or None
class XecGuardMissingCredentials(Exception):
pass
@ -88,6 +170,11 @@ class XecGuardGuardrail(CustomGuardrail):
api_base: str | None = None,
xecguard_model: str | None = None,
policy_names: list[str] | None = None,
apply_to_aliases: Sequence[str] | None = None,
except_aliases: Sequence[str] | None = None,
send_meta: bool | None = None,
meta_data_fields: Sequence[str] | None = None,
meta_identity_format: str | None = None,
block_on_error: bool | None = None,
grounding_strictness: str | None = None,
**kwargs: Any,
@ -105,6 +192,41 @@ class XecGuardGuardrail(CustomGuardrail):
self.xecguard_model = xecguard_model or _DEFAULT_MODEL
self.policy_names = policy_names
# Guardrail-side key targeting (free, OSS). Normalized to lists.
self.apply_to_aliases = apply_to_aliases or ()
self.except_aliases = except_aliases or ()
# Caller context forwarded as the scan payload's ``meta``. Opt-in: turning
# it on sends the calling key's alias and its admin-set metadata to
# XecGuard, which is a data-egress change no upgrade should make silently.
if send_meta is None:
self.send_meta = os.environ.get("XECGUARD_SEND_META", "false").lower() in (
"true",
"1",
"yes",
)
else:
self.send_meta = send_meta
self.meta_data_fields = tuple(meta_data_fields) if meta_data_fields else ()
# Wire shape of ``meta.virtualkey``. Defaults to the string form: a backend
# that only accepts strings answers the object form with 400, and with
# ``block_on_error`` on (the default) that turns every request into a block.
# An unknown value falls back rather than raising - a typo in the UI should
# not take the gateway down.
requested_format: Final = (
(meta_identity_format or os.environ.get("XECGUARD_META_IDENTITY_FORMAT") or "").strip().lower()
)
if requested_format and requested_format not in _META_IDENTITY_FORMATS:
verbose_proxy_logger.warning(
"XecGuard: unknown meta_identity_format %r - falling back to %r (valid: %s)",
requested_format,
_DEFAULT_META_IDENTITY_FORMAT,
", ".join(_META_IDENTITY_FORMATS),
)
self.meta_identity_format = (
requested_format if requested_format in _META_IDENTITY_FORMATS else _DEFAULT_META_IDENTITY_FORMAT
)
if block_on_error is None:
env: Final = os.environ.get("XECGUARD_BLOCK_ON_ERROR", "true")
@ -143,6 +265,93 @@ class XecGuardGuardrail(CustomGuardrail):
GuardrailEventHooks.logging_only,
]
@staticmethod
def _calling_key_identity(
request_data: Mapping[str, Any] | None,
) -> tuple[str | None, str | None]:
"""Return (key_alias, key_hash) of the calling virtual key from the
proxy-injected request metadata. Both may be None (e.g. master key)."""
if not isinstance(request_data, dict):
return None, None
sources: Final = tuple(
md
for md in (request_data.get(meta_key) for meta_key in ("metadata", "litellm_metadata"))
if isinstance(md, dict)
)
alias: Final = next((md["user_api_key_alias"] for md in sources if md.get("user_api_key_alias")), None)
key_hash: Final = next((md["user_api_key_hash"] for md in sources if md.get("user_api_key_hash")), None)
return alias, key_hash
def _key_is_targeted(self, request_data: Mapping[str, Any] | None) -> bool:
"""Guardrail-side key targeting. With no allow/block list configured,
every key is scanned. Otherwise the calling key is matched by alias
(preferred) or hashed token:
* blocklist (except_aliases): listed keys are NOT scanned;
* allowlist (apply_to_aliases): only listed keys are scanned.
When both are set, a key is scanned iff it is in the allowlist AND not
in the blocklist.
"""
allowlist: Final = self.apply_to_aliases or ()
blocklist: Final = self.except_aliases or ()
if not allowlist and not blocklist:
return True
alias, key_hash = self._calling_key_identity(request_data)
identifiers: Final = tuple(ident for ident in (alias, key_hash) if ident)
# Deny wins, and it is checked first so that precedence stays visible
# rather than folded into the allowlist expression below.
if blocklist and any(ident in blocklist for ident in identifiers):
return False
if not allowlist:
return True
return any(ident in allowlist for ident in identifiers)
# Metadata fields the proxy injects to identify the calling virtual key.
_KEY_IDENTITY_FIELDS = ("user_api_key_alias", "user_api_key_hash")
@classmethod
def _key_context(cls, data: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
"""Return a mapping ``_calling_key_identity`` can read the key fields from.
That reader looks for top-level ``metadata`` / ``litellm_metadata``. On the
pre/during/post_call paths the proxy already puts the injected key fields
there, so ``data`` is handed back untouched -- reshaping to a single key would
drop the other location it also reads. Only the logging path needs help: there
``data`` is ``model_call_details``, which carries the same fields one level
down under ``litellm_params``.
"""
if not isinstance(data, dict):
return data
for meta_key in ("metadata", "litellm_metadata"):
md = data.get(meta_key)
if isinstance(md, dict) and any(field in md for field in cls._KEY_IDENTITY_FIELDS):
return data
nested: Final = data.get("litellm_params")
if isinstance(nested, dict):
for meta_key in ("metadata", "litellm_metadata"):
md = nested.get(meta_key)
if isinstance(md, dict) and any(field in md for field in cls._KEY_IDENTITY_FIELDS):
return {meta_key: md} # mutable-ok: lifts nested metadata to the readers' shape
return data
def should_run_guardrail(self, data: Mapping[str, object], event_type: GuardrailEventHooks) -> bool:
"""Gate on the calling virtual key in addition to the native checks.
Deciding here rather than inside ``apply_guardrail`` is what makes LiteLLM
record the guardrail as not having run for a key this guardrail does not
cover, instead of logging a "success"/"allow" entry for a request it never
evaluated. ``super()`` is consulted first so the native decisions -- global
opt-outs, event-hook matching, tag-based modes -- keep precedence.
The gates are still enforced inside ``apply_guardrail`` and
``async_logging_hook`` as well: ``POST /guardrails/apply_guardrail`` invokes
``apply_guardrail`` directly and never reaches this method.
"""
if not super().should_run_guardrail(data, event_type):
return False
return self._key_is_targeted(self._key_context(data))
@log_guardrail_information
async def apply_guardrail(
self,
@ -151,6 +360,13 @@ class XecGuardGuardrail(CustomGuardrail):
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
# Guardrail-side key targeting (allowlist / blocklist by key alias):
# skip scanning entirely for keys this guardrail does not cover.
# should_run_guardrail already gates the proxy's own dispatch paths; this
# also covers POST /guardrails/apply_guardrail, which calls straight in.
if not self._key_is_targeted(self._key_context(request_data)):
return inputs
messages: Final = self._build_full_history(
request_data=request_data,
inputs=inputs,
@ -160,7 +376,11 @@ class XecGuardGuardrail(CustomGuardrail):
return inputs
scan_type: Final = "input" if input_type == "request" else "response"
scan_result: Final = await self._call_scan(messages=messages, scan_type=scan_type)
scan_result: Final = await self._call_scan(
messages=messages,
scan_type=scan_type,
request_data=request_data,
)
if scan_result is None:
return inputs
@ -214,6 +434,12 @@ class XecGuardGuardrail(CustomGuardrail):
):
return kwargs, result
# Same key targeting as apply_guardrail. logging_only reaches the guardrail
# through this hook rather than apply_guardrail, so the gate is repeated here;
# without it an excluded key's content would still be sent to XecGuard.
if not self._key_is_targeted(self._key_context(kwargs)):
return kwargs, result
start_time: Final = datetime.now()
try:
assistant_text: Final = self._extract_assistant_text_from_response(result)
@ -240,6 +466,7 @@ class XecGuardGuardrail(CustomGuardrail):
scan_result: Final = await self._call_scan(
messages=messages,
scan_type=scan_type,
request_data=request_data,
suppress_errors=True,
)
if scan_result is None:
@ -300,6 +527,197 @@ class XecGuardGuardrail(CustomGuardrail):
)
return kwargs, result
# ------------------------------------------------------------------
# Caller context (scan payload ``meta``) - SIEM correlation only
# ------------------------------------------------------------------
def _build_scan_meta(self, context: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
"""Assemble the scan payload's ``meta`` object, or None to omit it.
``virtualkey`` is the identity this guardrail filtered on and ``data`` is
the calling key's proxy-injected attributes plus its own metadata as set on
the Virtual Keys page. Neither participates in detection - XecGuard forwards
them to the SIEM so a scan can be traced back to the virtual key that caused
it.
``meta`` is optional in the contract, so anything that cannot be made to
satisfy it is left out instead of turning a scan into a 400.
"""
if not self.send_meta:
return None
virtualkey: Final[str | Mapping[str, str] | None] = (
self._scan_meta_virtualkey_object(context)
if self.meta_identity_format == "object"
else self._scan_meta_virtualkey(context)
)
if not virtualkey:
verbose_proxy_logger.debug(
"XecGuard: omitting scan meta - the calling key has no alias or hash matching the "
"backend's virtualkey pattern (give the key a key_alias to enable SIEM correlation)"
)
return None
meta: Final[dict[str, Any]] = {"virtualkey": virtualkey} # mutable-ok: the JSON object being assembled
data: Final = self._build_scan_meta_data(context, virtualkey=virtualkey)
if data:
meta["data"] = data
return meta
def _scan_meta_virtualkey(self, context: Mapping[str, Any] | None) -> str | None:
"""The key identity to report, or None when there is no usable one.
Alias first: that is what an operator types into ``apply_to_aliases`` /
``except_aliases``, so the value in the SIEM matches the value in the
guardrail config. The hashed token is the fallback for keys created
without an alias; a master-key call has neither. Note that the token
hash only satisfies the backend's pattern when it happens to start with
a hex letter - aliasless keys are not reliably correlatable.
"""
for candidate in self._calling_key_identity(context):
if isinstance(candidate, str) and _META_NAME_PATTERN.match(candidate):
return candidate
return None
def _scan_meta_virtualkey_object(self, context: Mapping[str, Any] | None) -> Mapping[str, str] | None:
"""The object form of ``virtualkey``: ``{"alias": ..., "key_id": ...}``.
Either member may be absent - a key created without an alias has only an
id, and a master-key call has neither (in which case ``meta`` is omitted).
Unlike the string form this does not require the alias to satisfy the
backend's identifier pattern: the pattern exists because a bare string
becomes a SIEM field *value* directly, whereas here each member is
sanitized the same way ``meta.data`` values are. That makes keys whose
alias contains spaces or CJK correlatable, which the string form cannot do.
"""
alias, key_hash = self._calling_key_identity(context)
obj: Final[dict[str, str]] = {} # mutable-ok: the JSON object being assembled
for name, raw in (("alias", alias), ("key_id", key_hash)):
value = self._coerce_meta_value(raw)
if value is not None:
obj[name] = value
return obj or None
@staticmethod
def _calling_key_metadata(context: Mapping[str, Any] | None) -> Mapping[object, Any]:
"""The calling virtual key's own metadata, as injected by the proxy.
This is the JSON an admin typed into the key's Metadata box on the
Virtual Keys page (minus the callback-credential slots, which the proxy
strips before injecting). Team metadata is deliberately not merged in:
``meta.data`` is meant to describe the key that made the call.
The key type is ``object``, not ``str``: nothing between the database and
here validates it, and the caller drops a non-str key rather than letting
it reach ``re.match`` and raise. Narrowing this to ``str`` would make that
guard look redundant to a type checker and invite its removal.
"""
if not isinstance(context, dict):
return {} # mutable-ok: "this key has no metadata"; the caller only reads it
for meta_key in ("metadata", "litellm_metadata"):
md = context.get(meta_key)
if isinstance(md, dict):
key_metadata = md.get(_METADATA_KEY_METADATA_FIELD)
if isinstance(key_metadata, dict):
return key_metadata
return {} # mutable-ok: same empty result, no metadata field was injected
@classmethod
def _auto_meta_data_items(cls, context: Mapping[str, Any] | None) -> tuple[tuple[str, Any], ...]:
"""The proxy-injected virtual-key attributes, in ``_META_AUTO_DATA_FIELDS``
order regardless of how the proxy ordered its metadata dict.
Absent and null fields are skipped, so a key with no team contributes no
``team_id`` rather than an empty one - a SIEM query for "scans with no
team" then means it, instead of matching every key.
"""
injected: Final[dict[str, Any]] = {} # mutable-ok: accumulator keyed by meta.data name
if isinstance(context, dict):
for meta_key in ("metadata", "litellm_metadata"):
md = context.get(meta_key)
if not isinstance(md, dict):
continue
for name, source_field in _META_AUTO_DATA_FIELDS:
if name not in injected and md.get(source_field) is not None:
injected[name] = md[source_field]
return tuple((name, injected[name]) for name, _ in _META_AUTO_DATA_FIELDS if name in injected)
def _build_scan_meta_data(
self, context: Mapping[str, Any] | None, virtualkey: str | Mapping[str, str]
) -> Mapping[str, str]:
"""Coerce the calling key's attributes and metadata into ``meta.data``.
Two sources, in this order: the attributes the proxy injects about the
calling key (identity, tenancy, budget), then the free-form metadata an
admin typed into the key's Metadata box. Proxy-injected attributes go
first and win a name collision, so an admin cannot shadow ``key_id`` with
a field of their own and mislead an investigation.
Fields are kept while they satisfy the contract: a name matching the
backend's pattern, a flat scalar value, at most 32 fields, and a
serialized ``meta`` within the 4096-byte cap. Oversize fields are skipped
rather than ending the scan, so a later small field still gets through.
Dropped names are logged without their values - both sources can hold
sensitive strings.
"""
source: Final = self._calling_key_metadata(context)
data: Final[dict[str, str]] = {} # mutable-ok: accumulator, re-measured as it grows
# Re-measured against the real payload shape each time, so the cap holds
# regardless of how long the virtualkey and the field names are.
probe: Final[dict[str, Any]] = {"virtualkey": virtualkey, "data": data} # mutable-ok: views `data`
dropped: Final[list[str]] = [] # mutable-ok: skipped field names, for one debug line
for name, raw_value in (*self._auto_meta_data_items(context), *source.items()):
if self.meta_data_fields:
if name not in self.meta_data_fields:
continue
elif name in _META_CONTROL_DATA_FIELDS:
# proxy config rather than caller identity - opt in by name
continue
if name in _META_EXCLUDED_DATA_FIELDS:
continue
if name in data: # a proxy-injected attribute already claimed this name
dropped.append(str(name))
continue
if not isinstance(name, str) or not _META_NAME_PATTERN.match(name):
dropped.append(str(name))
continue
if len(data) >= _META_MAX_DATA_FIELDS:
dropped.append(name)
continue
value = self._coerce_meta_value(raw_value)
if value is None:
dropped.append(name)
continue
data[name] = value
if len(json.dumps(probe, ensure_ascii=False).encode("utf-8")) > _META_MAX_SERIALIZED_BYTES:
del data[name]
dropped.append(name)
if dropped:
verbose_proxy_logger.debug(
"XecGuard: scan meta.data dropped %d field(s) (names only): %s",
len(dropped),
dropped,
)
return data
@staticmethod
def _coerce_meta_value(value: object) -> str | None:
"""Coerce one key-metadata value to the contract, or None to drop it.
Scalars are stringified so an admin writing ``{"tier": 3}`` still gets a
usable ``ctx_tier``. Nested objects and lists have no flat representation
a SIEM index field can hold, so they are dropped.
"""
if isinstance(value, bool):
return _sanitized_meta_text("true" if value else "false")
if isinstance(value, str):
return _sanitized_meta_text(value)
if isinstance(value, (int, float)):
return _sanitized_meta_text(str(value))
return None
# ------------------------------------------------------------------
# HTTP helpers
# ------------------------------------------------------------------
@ -308,6 +726,7 @@ class XecGuardGuardrail(CustomGuardrail):
self,
messages: list[dict],
scan_type: str,
request_data: Mapping[str, Any] | None = None,
suppress_errors: bool = False,
) -> dict | None:
payload: Final[dict[str, Any]] = {
@ -316,6 +735,9 @@ class XecGuardGuardrail(CustomGuardrail):
"messages": messages,
"policy_names": (self.policy_names if self.policy_names else _DEFAULT_POLICIES),
}
meta: Final = self._build_scan_meta(self._key_context(request_data))
if meta is not None:
payload["meta"] = meta
return await self._post(
path=_SCAN_ENDPOINT,
payload=payload,

View file

@ -1,6 +1,6 @@
from typing import Final, Literal
from pydantic import Field
from pydantic import Field, field_validator
from .base import GuardrailConfigModel
@ -48,6 +48,69 @@ class XecGuardConfigModel(GuardrailConfigModel):
"options": list(XECGUARD_DEFAULT_POLICY_OPTIONS),
},
)
apply_to_aliases: str | list[str] | None = Field( # mutable-ok: list sets UI type; Sequence ambiguous
default=None,
description=(
"Allowlist of virtual-key aliases: only requests from keys whose "
"alias is listed here are scanned by this guardrail. Leave empty to "
"apply to all keys (subject to the exclude list below). Accepts a "
"list or a comma-separated string."
),
)
except_aliases: str | list[str] | None = Field( # mutable-ok: list sets UI type; Sequence ambiguous
default=None,
description=(
"Exclude list of virtual-key aliases: requests from keys whose alias "
"is listed here are NOT scanned by this guardrail (exempted), even "
"when the allowlist is empty. Accepts a list or a comma-separated "
"string."
),
)
send_meta: bool | None = Field(
default=None,
description=(
"Forward caller context to XecGuard as the scan payload's `meta` "
"object: `meta.virtualkey` is the calling virtual key's alias (its "
"token hash when it has no alias) and `meta.data` is that key's own "
"metadata from the Virtual Keys page. It takes no part in the "
"verdict - XecGuard flattens it into the SIEM event (ctx_virtualkey, "
"ctx_<field>) so scans can be traced back to the key that caused "
"them. Defaults to false; falls back to the XECGUARD_SEND_META env "
"var."
),
)
meta_data_fields: str | list[str] | None = Field( # mutable-ok: list sets UI type; Sequence ambiguous
default=None,
description=(
"Restrict which of the virtual key's metadata fields are forwarded "
"in `meta.data`. Leave empty to send every field that fits the "
"backend's contract (flat scalar values, at most 32 fields, 512 "
"characters each), minus the proxy's own per-key control settings "
"(rate limits, budget knobs, enforced params) which are skipped as "
"SIEM noise - naming one here opts it back in. Callback credential "
"slots are never forwarded either way. Accepts a list or a "
"comma-separated string. Only used when `send_meta` is enabled."
),
)
# Named `meta_identity_format`, not `meta_virtualkey_format`: the proxy masks
# any litellm_param whose name contains "key" before serving it back, so a
# `virtualkey` in the name means the UI form prefills "ob****ct" and saving
# the form writes that back - the plugin then falls through to the default and
# the admin's choice is lost with no error. See the masking regression test.
meta_identity_format: Literal["string", "object"] | None = Field(
default=None,
description=(
"Wire shape of `meta.virtualkey`. 'string' (default) sends the alias "
"as a bare string and is what current XecGuard backends accept. "
"'object' sends `{alias, key_id}` so a scan stays attributable when "
"the key has no alias or the alias was renamed or reused, and lifts "
"the identifier-pattern restriction on aliases - it requires a "
"backend that validates the object form, otherwise every scan is "
"rejected with 400. Falls back to the "
"XECGUARD_META_IDENTITY_FORMAT env var. Only used when `send_meta` "
"is enabled."
),
)
block_on_error: bool | None = Field(
default=None,
description=(
@ -67,6 +130,19 @@ class XecGuardConfigModel(GuardrailConfigModel):
),
)
@field_validator("apply_to_aliases", "except_aliases", "meta_data_fields", mode="before")
@classmethod
def _normalize_alias_list(cls, v: object) -> object:
"""Accept either a list or a comma-separated string (the UI submits a
plain text box as a string; YAML users may write a list) and normalize
to a de-whitespaced, empties-dropped list of aliases / field names."""
if v is None:
return None
items: Final = v.split(",") if isinstance(v, str) else v
if isinstance(items, (list, tuple)):
return [s.strip() for s in items if isinstance(s, str) and s.strip()] # mutable-ok: tests assert this list
return v
@staticmethod
def ui_friendly_name() -> str:
return "XecGuard"

View file

@ -61,9 +61,11 @@ async def test_apply_guardrail_endpoint_returns_correct_response(
assert response.response_text == "Redacted text: [REDACTED] and [REDACTED]"
# Verify the guardrail was called with correct parameters
# the endpoint always forwards the proxy's own metadata, which this
# fixture leaves empty, so the guardrail can read the calling key
mock_guardrail.apply_guardrail.assert_called_once_with(
inputs={"texts": ["Test text with PII"]},
request_data={},
request_data={"metadata": {}},
input_type="request",
)
@ -198,5 +200,7 @@ async def test_apply_guardrail_endpoint_without_optional_params(mock_proxy_loggi
# Verify the guardrail was called with correct parameters
mock_guardrail.apply_guardrail.assert_called_once_with(
inputs={"texts": ["Test text"]}, request_data={}, input_type="request"
inputs={"texts": ["Test text"]},
request_data={"metadata": {}},
input_type="request",
)

View file

@ -1497,7 +1497,7 @@ async def test_apply_guardrail_invokes_logging_pipeline(mocker):
}
def _patch_apply_guardrail_env(mocker, guardrail_result):
def _patch_apply_guardrail_env(mocker, guardrail_result, proxy_metadata=None):
mock_guardrail = mocker.Mock()
mock_guardrail.apply_guardrail = AsyncMock(return_value=guardrail_result)
@ -1511,8 +1511,12 @@ def _patch_apply_guardrail_env(mocker, guardrail_result):
mock_logging_obj.async_success_handler = AsyncMock()
mock_logging_obj.model_call_details = {}
mock_processor = mocker.Mock()
processed_data = {
"guardrail_name": "test-guardrail",
**({"metadata": proxy_metadata} if proxy_metadata is not None else {}),
}
mock_processor.common_processing_pre_call_logic = AsyncMock(
return_value=({"guardrail_name": "test-guardrail"}, mock_logging_obj)
return_value=(processed_data, mock_logging_obj)
)
mocker.patch(
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
@ -1584,9 +1588,17 @@ async def test_apply_guardrail_forwards_metadata_and_messages_together(mocker):
@pytest.mark.asyncio
async def test_apply_guardrail_omits_metadata_when_not_sent(mocker):
"""Without metadata, request_data stays empty (backward-compatible)."""
mock_guardrail = _patch_apply_guardrail_env(mocker, {"texts": ["ok"]})
async def test_apply_guardrail_forwards_proxy_identity_when_body_has_no_metadata(mocker):
"""Guardrails that target specific virtual keys need the authenticated key
even when the body carries no metadata of its own."""
proxy_metadata = {
"route": "/apply_guardrail",
"user_api_key_alias": "prod-key",
"user_api_key_hash": "hash-abc",
}
mock_guardrail = _patch_apply_guardrail_env(
mocker, {"texts": ["ok"]}, proxy_metadata=proxy_metadata
)
request = ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello")
await apply_guardrail(
@ -1597,11 +1609,45 @@ async def test_apply_guardrail_omits_metadata_when_not_sent(mocker):
mock_guardrail.apply_guardrail.assert_awaited_once_with(
inputs={"texts": ["hello"]},
request_data={},
request_data={"metadata": proxy_metadata},
input_type="request",
)
@pytest.mark.asyncio
async def test_apply_guardrail_proxy_identity_overrides_caller_metadata(mocker):
"""A caller must not be able to name a virtual key other than the one that
authenticated, while the body's non-identity fields still reach the guardrail."""
mock_guardrail = _patch_apply_guardrail_env(
mocker,
{"texts": ["ok"]},
proxy_metadata={
"user_api_key_alias": "authenticated-key",
"user_api_key_hash": "hash-real",
},
)
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="hello",
metadata={
"user_api_key_alias": "spoofed-key",
"user_api_key_hash": "hash-spoofed",
"forbidden_topics": ["tax"],
},
)
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=request,
user_api_key_dict=UserAPIKeyAuth(),
)
forwarded = mock_guardrail.apply_guardrail.await_args.kwargs["request_data"]["metadata"]
assert forwarded["user_api_key_alias"] == "authenticated-key"
assert forwarded["user_api_key_hash"] == "hash-real"
assert forwarded["forbidden_topics"] == ["tax"]
@pytest.mark.asyncio
async def test_apply_guardrail_forwards_explicit_empty_messages_and_metadata(mocker):
"""Explicitly-sent empty messages/metadata must be forwarded, not dropped;