[feat]: Add opt-in evidence results for Pillar Security guardrail during monitoring (#17812)

* add evidence headers to litellm

* ensure that evidence is surface-able, even in opt-in mode

* update the docs
This commit is contained in:
Ariel 2025-12-12 14:09:13 +02:00 • committed by GitHub
parent f8e7e153d5
commit 5df701d15c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 416 additions and 7 deletions

View file

@ -233,7 +233,7 @@ curl -X POST "http://localhost:4000/v1/chat/completions" \
}'
```
This provides clear, explicit conversation tracking that works seamlessly with LiteLLM's session management.
This provides clear, explicit conversation tracking that works seamlessly with LiteLLM's session management. When using monitor mode, the session ID is returned in the `x-pillar-session-id` response header for easy correlation and tracking.
### Actions on Flagged Content
@ -251,6 +251,73 @@ Logs the violation but allows the request to proceed:
on_flagged_action: "monitor"
```
**Response Headers:**
You can opt in to receiving detection details in response headers by configuring `include_scanners: true` and/or `include_evidence: true`. When enabled, these headers are included for **every request**—not just flagged ones—enabling comprehensive metrics, false positive analysis, and threat investigation.
- **`x-pillar-flagged`**: Boolean string indicating Pillar's blocking recommendation (`"true"` or `"false"`)
- **`x-pillar-scanners`**: URL-encoded JSON object showing scanner categories (e.g., `%7B%22jailbreak%22%3Atrue%7D`) — requires `include_scanners: true`
- **`x-pillar-evidence`**: URL-encoded JSON array of detection evidence (may contain items even when `flagged` is `false`) — requires `include_evidence: true`
- **`x-pillar-session-id`**: URL-encoded session ID for correlation and investigation
:::info Understanding `flagged` vs Scanner Results
The `flagged` field is Pillar's **policy-level blocking recommendation**, which may differ from individual scanner results:
- **`flagged: true`** → Pillar recommends blocking based on your configured policies
- **`flagged: false`** → Pillar does not recommend blocking, but individual scanners may still detect content
For example, the `toxic_language` scanner might detect profanity (`scanners.toxic_language: true`) while `flagged` remains `false` if your Pillar policy doesn't block on toxic language alone. This allows you to:
- Monitor threats without blocking users
- Build metrics on detection rates vs block rates
- Analyze false positive rates by comparing scanner results to user feedback
:::
The `x-pillar-scanners`, `x-pillar-evidence`, and `x-pillar-session-id` headers use URL encoding (percent-encoding) to convert JSON data into an ASCII-safe format. This is necessary because HTTP headers only support ISO-8859-1 characters and cannot contain raw JSON special characters (`{`, `"`, `:`) or Unicode text. To read these headers, first URL-decode the value, then parse it as JSON.
LiteLLM truncates the `x-pillar-evidence` header to a maximum of 8 KB per header to avoid proxy limits. Note that most proxies and servers also enforce a total header size limit of approximately 32 KB across all headers combined. When truncation occurs, each affected evidence item includes an `"evidence_truncated": true` flag and the metadata contains `pillar_evidence_truncated: true`.
**Example Response Headers (URL-encoded):**
```http
x-pillar-flagged: true
x-pillar-session-id: abc-123-def-456
x-pillar-scanners: %7B%22jailbreak%22%3Atrue%2C%22prompt_injection%22%3Afalse%2C%22toxic_language%22%3Afalse%7D
x-pillar-evidence: %5B%7B%22category%22%3A%22prompt_injection%22%2C%22evidence%22%3A%22Ignore%20previous%20instructions%22%7D%5D
```
**After Decoding:**
```json
// x-pillar-scanners
{"jailbreak": true, "prompt_injection": false, "toxic_language": false}
// x-pillar-evidence
[{"category": "prompt_injection", "evidence": "Ignore previous instructions"}]
```
**Decoding Example (Python):**
```python
from urllib.parse import unquote
import json
# Step 1: URL-decode the header value (converts %7B to {, %22 to ", etc.)
# Step 2: Parse the resulting JSON string
scanners = json.loads(unquote(response.headers["x-pillar-scanners"]))
evidence = json.loads(unquote(response.headers["x-pillar-evidence"]))
# Session ID is a plain string, so only URL-decode is needed (no JSON parsing)
session_id = unquote(response.headers["x-pillar-session-id"])
```
:::tip
LiteLLM mirrors the encoded values onto `metadata["pillar_response_headers"]` so you can inspect exactly what was returned. When truncation occurs, it sets `metadata["pillar_evidence_truncated"]` to `true` and marks affected evidence items with `"evidence_truncated": true`. Evidence text is shortened with a `...[truncated]` suffix, and entire evidence entries may be removed if necessary to stay under the 8 KB header limit. Check these flags to determine if full evidence details are available in your logs.
:::
This allows your application to:
- Track threats without blocking legitimate users
- Implement custom handling logic based on threat types
- Build analytics and alerting on security events
- Correlate threats across requests using session IDs
### Resilience and Error Handling
#### Graceful Degradation (`fallback_on_error`)
@ -544,6 +611,79 @@ curl -X POST "http://localhost:4000/v1/chat/completions" \
}
```
</TabItem>
<TabItem value="monitor" label="Monitor Mode with Headers">
**Monitor mode request with scanner detection:**
```bash
# Test with content that triggers scanner detection
curl -v -X POST "http://localhost:4000/v1/chat/completions" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_LITELLM_PROXY_MASTER_KEY" \
-d '{
"model": "gpt-4.1-mini",
"messages": [{"role": "user", "content": "how do I rob a bank?"}],
"max_tokens": 50
}'
```
**Expected response (Allowed with headers):**
The request succeeds and returns the LLM response. Headers are included for **all requests** when `include_scanners` and `include_evidence` are enabled—even when `flagged` is `false`:
```http
HTTP/1.1 200 OK
x-litellm-applied-guardrails: pillar-monitor-everything,pillar-monitor-everything
x-pillar-flagged: false
x-pillar-scanners: %7B%22jailbreak%22%3Afalse%2C%22safety%22%3Atrue%2C%22prompt_injection%22%3Afalse%2C%22pii%22%3Afalse%2C%22secret%22%3Afalse%2C%22toxic_language%22%3Afalse%7D
x-pillar-evidence: %5B%7B%22category%22%3A%22safety%22%2C%22type%22%3A%22non_violent_crimes%22%2C%22end_idx%22%3A20%2C%22evidence%22%3A%22how%20do%20I%20rob%20a%20bank%3F%22%2C%22metadata%22%3A%7B%22start_idx%22%3A0%2C%22end_idx%22%3A20%7D%7D%5D
x-pillar-session-id: d9433f86-b428-4ee7-93ee-e97a53f8a180
```
Notice that `x-pillar-flagged: false` but `safety: true` in the scanners. This is because `flagged` represents Pillar's policy-level blocking recommendation, while individual scanners report their own detections.
```python
from urllib.parse import unquote
import json
scanners = json.loads(unquote(response.headers["x-pillar-scanners"]))
evidence = json.loads(unquote(response.headers["x-pillar-evidence"]))
session_id = unquote(response.headers["x-pillar-session-id"])
flagged = response.headers["x-pillar-flagged"] == "true"
# Scanner detected safety issue, but policy didn't flag for blocking
print(f"Flagged for blocking: {flagged}") # False
print(f"Safety issue detected: {scanners.get('safety')}") # True
print(f"Evidence: {evidence}")
# [{'category': 'safety', 'type': 'non_violent_crimes', 'evidence': 'how do I rob a bank?', ...}]
```
```json
{
"id": "chatcmpl-xyz123",
"object": "chat.completion",
"model": "gpt-4.1-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "I'm sorry, but I can't assist with that request."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 14,
"completion_tokens": 11,
"total_tokens": 25
}
}
```
**Note:** In monitor mode, scanner results and evidence are included in response headers for every request, allowing you to build metrics and analyze detection patterns. The `flagged` field indicates whether Pillar's policy recommends blocking—your application can use the detailed scanner data for custom alerting, analytics, or false positive analysis.
</TabItem>
<TabItem value="secrets" label="Secrets">

View file

@ -359,7 +359,11 @@ def get_remaining_tokens_and_requests_from_request_data(data: Dict) -> Dict[str,
def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
_metadata = request_data.get("metadata", None) or {}
_metadata = request_data.get("metadata", None)
if not _metadata:
_metadata = request_data.get("litellm_metadata", None)
if not isinstance(_metadata, dict):
_metadata = {}
headers = {}
if "applied_guardrails" in _metadata:
headers["x-litellm-applied-guardrails"] = ",".join(
@ -369,6 +373,12 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
if "semantic-similarity" in _metadata:
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])
pillar_headers = _metadata.get("pillar_response_headers")
if isinstance(pillar_headers, dict):
headers.update(pillar_headers)
elif "pillar_flagged" in _metadata:
headers["x-pillar-flagged"] = str(_metadata["pillar_flagged"]).lower()
return headers

View file

@ -6,8 +6,10 @@
# +-------------------------------------------------------------+
# Standard library imports
import json
import os
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, Type, Union
from urllib.parse import quote
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, Union
# Third-party imports
from fastapi import HTTPException
@ -28,6 +30,7 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
get_metadata_variable_name_from_kwargs,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import LLMResponseTypes
@ -35,6 +38,109 @@ from litellm.types.utils import LLMResponseTypes
if TYPE_CHECKING:
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
MAX_PILLAR_HEADER_VALUE_BYTES = 8 * 1024
def _encode_json_for_header(data: Any) -> str:
"""
JSON-serialize and URL-encode data for safe header transmission.
"""
json_payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return quote(json_payload, safe="")
def _truncate_evidence_payload(
evidence: Any, max_bytes: int = MAX_PILLAR_HEADER_VALUE_BYTES
) -> Tuple[Any, str, bool]:
"""
Truncate evidence payload so the encoded header value stays within max_bytes.
Returns:
truncated_evidence: Evidence list/value after truncation
encoded_value: URL-encoded JSON string for header
was_truncated: Whether truncation occurred
"""
if not isinstance(evidence, list):
encoded = _encode_json_for_header(evidence)
if len(encoded.encode("utf-8")) <= max_bytes:
return evidence, encoded, False
truncated_value = "[truncated]"
return truncated_value, _encode_json_for_header(truncated_value), True
truncated: List[Any] = []
encoded = _encode_json_for_header(truncated)
truncated_flag = False
for entry in evidence:
working_entry: Any
if isinstance(entry, dict):
working_entry = dict(entry)
else:
working_entry = entry
truncated.append(working_entry)
encoded = _encode_json_for_header(truncated)
if len(encoded.encode("utf-8")) <= max_bytes:
continue
truncated_flag = True
if isinstance(working_entry, dict):
evidence_text = str(working_entry.get("evidence", ""))
if evidence_text:
step = max(1, len(evidence_text) // 2)
while len(encoded.encode("utf-8")) > max_bytes and evidence_text:
evidence_text = (
evidence_text[:-step] if len(evidence_text) > step else evidence_text[:-1]
)
step = max(1, step // 2)
truncated_text = (
f"{evidence_text}...[truncated]" if evidence_text else "[truncated]"
)
working_entry["evidence"] = truncated_text
working_entry["evidence_truncated"] = True
encoded = _encode_json_for_header(truncated)
if len(encoded.encode("utf-8")) <= max_bytes:
continue
truncated.pop()
encoded = _encode_json_for_header(truncated)
return truncated, encoded, truncated_flag
def build_pillar_response_headers(metadata_store: Dict[str, Any]) -> Dict[str, str]:
"""
Create URL-safe Pillar response headers and apply truncation metadata.
"""
headers: Dict[str, str] = {}
if "pillar_flagged" in metadata_store:
headers["x-pillar-flagged"] = str(metadata_store["pillar_flagged"]).lower()
if "pillar_scanners" in metadata_store:
headers["x-pillar-scanners"] = _encode_json_for_header(metadata_store["pillar_scanners"])
if "pillar_evidence" in metadata_store:
truncated_evidence, encoded_value, truncated_flag = _truncate_evidence_payload(
metadata_store["pillar_evidence"]
)
metadata_store["pillar_evidence"] = truncated_evidence
if truncated_flag:
metadata_store["pillar_evidence_truncated"] = True
headers["x-pillar-evidence"] = encoded_value
if "pillar_session_id_response" in metadata_store:
headers["x-pillar-session-id"] = quote(
str(metadata_store["pillar_session_id_response"]), safe=""
)
if headers:
metadata_store["pillar_response_headers"] = headers
return headers
# Exception classes
class PillarGuardrailMissingSecrets(Exception):
@ -637,15 +743,31 @@ class PillarGuardrail(CustomGuardrail):
flagged = pillar_response.get("flagged", False)
metadata_field = get_metadata_variable_name_from_kwargs(original_data)
if metadata_field not in original_data or not isinstance(original_data.get(metadata_field), dict):
original_data[metadata_field] = {}
metadata_store = original_data[metadata_field]
# Backwards compatibility - ensure metadata alias exists when different key used
if metadata_field != "metadata":
if "metadata" not in original_data or not isinstance(original_data.get("metadata"), dict):
original_data["metadata"] = metadata_store
# Store session_id from Pillar response for potential reuse
pillar_session_id = pillar_response.get("session_id")
if pillar_session_id:
verbose_proxy_logger.debug(f"Pillar Guardrail: Received session_id from server: {pillar_session_id}")
# Store in request metadata for use in subsequent hooks
if "metadata" not in original_data:
original_data["metadata"] = {}
if "pillar_session_id" not in original_data["metadata"]:
original_data["metadata"]["pillar_session_id"] = pillar_session_id
if "pillar_session_id" not in metadata_store:
metadata_store["pillar_session_id"] = pillar_session_id
metadata_store["pillar_session_id_response"] = pillar_session_id
# Always set flagged status and scanner/evidence data for monitor mode
metadata_store["pillar_flagged"] = flagged
if self.include_scanners:
metadata_store["pillar_scanners"] = pillar_response.get("scanners", {})
if self.include_evidence:
metadata_store["pillar_evidence"] = pillar_response.get("evidence", [])
if flagged:
verbose_proxy_logger.warning("Pillar Guardrail: Threat detected")
@ -654,6 +776,8 @@ class PillarGuardrail(CustomGuardrail):
elif self.on_flagged_action == "monitor":
verbose_proxy_logger.info("Pillar Guardrail: Monitoring mode - allowing flagged content to proceed")
build_pillar_response_headers(metadata_store)
def _raise_pillar_detection_exception(self, pillar_response: Dict[str, Any]) -> None:
"""
Raise an HTTPException for Pillar security detections.

View file

@ -15,6 +15,9 @@ from unittest.mock import Mock, patch
sys.path.insert(0, os.path.abspath("../../.."))
# Third-party imports
import json
from urllib.parse import unquote
import pytest
from fastapi.exceptions import HTTPException
from httpx import Request, Response
@ -23,11 +26,15 @@ from httpx import Request, Response
import litellm
from litellm import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import get_logging_caching_headers
from litellm.proxy.guardrails.guardrail_hooks.pillar import (
PillarGuardrail,
PillarGuardrailAPIError,
PillarGuardrailMissingSecrets,
)
from litellm.proxy.guardrails.guardrail_hooks.pillar.pillar import (
build_pillar_response_headers,
)
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
@ -169,6 +176,7 @@ def pillar_clean_response():
"pii": False,
"toxic_language": False,
},
"evidence": [],
},
status_code=200,
request=Request(
@ -402,6 +410,133 @@ async def test_pre_call_hook_flagged_content_monitor(
)
assert result == malicious_request_data
assert "metadata" in malicious_request_data
metadata = malicious_request_data["metadata"]
assert metadata.get("pillar_flagged") is True
assert metadata.get("pillar_session_id") == pillar_flagged_response.json()["session_id"]
assert metadata.get("pillar_session_id_response") == pillar_flagged_response.json()["session_id"]
assert metadata.get("pillar_scanners") == pillar_flagged_response.json().get("scanners", {})
assert metadata.get("pillar_evidence") == pillar_flagged_response.json().get("evidence", [])
@pytest.mark.asyncio
async def test_pre_call_hook_clean_content_returns_scanners_and_evidence(
pillar_monitor_guardrail,
sample_request_data,
user_api_key_dict,
dual_cache,
pillar_clean_response,
):
"""Test that scanners and evidence are returned even when content is not flagged."""
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
return_value=pillar_clean_response,
):
result = await pillar_monitor_guardrail.async_pre_call_hook(
data=sample_request_data,
cache=dual_cache,
user_api_key_dict=user_api_key_dict,
call_type="completion",
)
assert result == sample_request_data
assert "metadata" in sample_request_data
metadata = sample_request_data["metadata"]
# Even when not flagged, we should get scanners and evidence
assert metadata.get("pillar_flagged") is False
# pillar_session_id preserves existing value, pillar_session_id_response is always from response
assert metadata.get("pillar_session_id_response") == pillar_clean_response.json()["session_id"]
assert metadata.get("pillar_scanners") == pillar_clean_response.json().get("scanners", {})
assert metadata.get("pillar_evidence") == pillar_clean_response.json().get("evidence", [])
# Verify headers are also built
headers = get_logging_caching_headers(sample_request_data)
assert headers["x-pillar-flagged"] == "false"
assert json.loads(unquote(headers["x-pillar-scanners"])) == pillar_clean_response.json().get("scanners", {})
def test_get_logging_caching_headers_pillar_metadata():
scanners = {"toxic_language": True, "jailbreak": False}
evidence = [{"category": "toxic_language", "evidence": "example"}]
request_data = {
"metadata": {
"pillar_flagged": True,
"pillar_scanners": scanners,
"pillar_evidence": evidence,
"pillar_session_id_response": "test-session-123",
}
}
build_pillar_response_headers(request_data["metadata"])
headers = get_logging_caching_headers(request_data)
assert headers["x-pillar-flagged"] == "true"
assert json.loads(unquote(headers["x-pillar-scanners"])) == scanners
assert json.loads(unquote(headers["x-pillar-evidence"])) == evidence
assert unquote(headers["x-pillar-session-id"]) == "test-session-123"
assert request_data["metadata"]["pillar_response_headers"]["x-pillar-flagged"] == "true"
def test_get_logging_caching_headers_truncates_large_evidence():
long_text = "悪" * 6000 # multi-byte unicode to test URL encoding and truncation
request_data = {
"metadata": {
"pillar_evidence": [{"category": "unicode", "evidence": long_text}],
}
}
build_pillar_response_headers(request_data["metadata"])
headers = get_logging_caching_headers(request_data)
evidence_header = headers["x-pillar-evidence"]
assert len(evidence_header.encode("utf-8")) <= 8 * 1024
decoded_evidence = json.loads(unquote(evidence_header))
assert decoded_evidence
assert decoded_evidence[0]["evidence"].endswith("...[truncated]")
assert decoded_evidence[0].get("evidence_truncated") is True
assert request_data["metadata"]["pillar_evidence_truncated"] is True
assert request_data["metadata"]["pillar_response_headers"]["x-pillar-evidence"] == evidence_header
@pytest.mark.asyncio
async def test_post_call_hook_flagged_content_monitor_updates_metadata_and_headers(
pillar_monitor_guardrail,
malicious_request_data,
user_api_key_dict,
pillar_flagged_response,
mock_llm_response,
):
"""Ensure post-call monitor verdicts update shared metadata and headers."""
request_data = malicious_request_data.copy()
request_data["metadata"] = {}
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
return_value=pillar_flagged_response,
):
response = await pillar_monitor_guardrail.async_post_call_success_hook(
data=request_data,
user_api_key_dict=user_api_key_dict,
response=mock_llm_response,
)
assert response is mock_llm_response
metadata = request_data["metadata"]
pillar_json = pillar_flagged_response.json()
assert metadata.get("pillar_flagged") is True
assert metadata.get("pillar_session_id") == pillar_json["session_id"]
assert metadata.get("pillar_session_id_response") == pillar_json["session_id"]
assert metadata.get("pillar_scanners") == pillar_json.get("scanners", {})
assert metadata.get("pillar_evidence") == pillar_json.get("evidence", [])
headers = get_logging_caching_headers(request_data)
assert headers["x-pillar-flagged"] == "true"
assert json.loads(unquote(headers["x-pillar-scanners"])) == pillar_json.get("scanners", {})
assert json.loads(unquote(headers["x-pillar-evidence"])) == pillar_json.get("evidence", [])
assert unquote(headers["x-pillar-session-id"]) == pillar_json["session_id"]
assert request_data["metadata"]["pillar_response_headers"]["x-pillar-session-id"] == headers["x-pillar-session-id"]
@pytest.mark.asyncio