diff --git a/docs/my-website/docs/proxy/guardrails/pillar_security.md b/docs/my-website/docs/proxy/guardrails/pillar_security.md index 9632376768b..de0b0d53614 100644 --- a/docs/my-website/docs/proxy/guardrails/pillar_security.md +++ b/docs/my-website/docs/proxy/guardrails/pillar_security.md @@ -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" \ } ``` + + + +**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. + diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 90f82580f1e..76c54332fa3 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py b/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py index 74903cc52a4..0df610177e5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py @@ -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. diff --git a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py index 99a51d20a7d..2e7443e889f 100644 --- a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py @@ -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