Guardrails
Guardrails inspect the content of requests and responses in real time — before a prompt reaches a provider and before a response reaches the caller. A guardrail config bundles a set of built-in checks, the surfaces they run on, and the resource groups they apply to (group_ids). Multiple configs can apply to the same request; the strictest outcome wins.

Built-in checks
Section titled “Built-in checks”| Check | What it detects | Providers | Key settings |
|---|---|---|---|
| Prompt injection | Attempts to override instructions | built_in, lakera_guard |
prompt_injection_sensitivity (0.0–1.0) |
| Jailbreak | Role-play / policy-evasion attacks | built_in, lakera_guard |
— |
| PII | Personal data in traffic | built_in, presidio |
pii_categories, pii_threshold (Presidio score threshold) |
| Toxic content | Hate, harassment, violence, sexual content, self-harm | openai_moderation, built_in |
toxic_content_threshold, per-category thresholds |
| Secrets | Credentials leaking through prompts/outputs | built-in | secrets_categories, secrets_threshold |
| Banned words | Literal word/phrase list | built-in | banned_words, banned_words_match_mode (substring / word_boundary) |
| Banned patterns | Regex list | built-in | banned_patterns |
Category values you can select:
- PII (
pii_categories):email,phone,ssn,credit_card,iban,ipv4,ipv6,openai_api_key,anthropic_api_key,aws_access_key,jwt,private_key - Secrets (
secrets_categories):aws_access_key,aws_secret_key,github_token,openai_key,gcp_service_account,private_key_pem,jwt,generic_high_entropy - Toxicity per-category thresholds (
toxic_content_category_thresholds) use the OpenAI Moderation taxonomy:hate,hate/threatening,harassment,harassment/threatening,self-harm,self-harm/intent,self-harm/instructions,sexual,sexual/minors,violence,violence/graphic
Actions
Section titled “Actions”Each check has an action: block (reject the request/response), redact (mask the offending spans and let the traffic continue — PII and secrets are the typical redact candidates), warn (log a violation, let it pass), or allow.
The two-layer surface gate
Section titled “The two-layer surface gate”This is the most common source of “my guardrail isn’t firing” confusion, so read this twice.

A check runs on a surface only where BOTH layers enable it:
- Config-level
surfacesmap — turns each surface on/off for the whole config. - Per-check
*_surfaceslist (e.g.pii_surfaces) — the surfaces that specific check participates in. If unset, the check uses its built-in default surface set.
check fires on surface S ⇔ surfaces[S] == true AND S ∈ <check>_surfacesExample. This config enables the mcp_input surface, but the PII check lists only chat surfaces — so PII will not scan MCP tool arguments, even though the surface is on:
{ "surfaces": { "chat_input": true, "chat_output": true, "mcp_input": true }, "builtin": { "pii_enabled": true, "pii_action": "redact", "pii_surfaces": ["chat_input", "chat_output"] }}To scan MCP arguments too, add "mcp_input" to pii_surfaces. Conversely, a long per-check surface list is inert on any surface the config-level map leaves off.
Full surface list
Section titled “Full surface list”LLM surfaces: chat_input, chat_output, embeddings_input, image_gen_input, image_gen_output, audio_tts_input, audio_tts_output, audio_stt_input, audio_stt_output, video_gen_input, video_gen_output, batch_input, batch_output, moderation_input
Non-LLM surfaces: mcp_input, mcp_output, mcp_registration, skill_input, skill_output, a2a_inbound, a2a_outbound
Per-surface overrides exist for most checks (pii_per_surface, toxic_content_per_surface, secrets_per_surface, banned_words_per_surface, …) so you can, for example, run PII with redact on chat but block on MCP output. For banned words/patterns, a per-surface words/patterns field replaces the global list on that surface.
Streaming output modes
Section titled “Streaming output modes”Output checks on SSE streams need a strategy: you can’t redact a token that has already left the building. streaming_output_mode on the config controls this:
| Mode | Behavior |
|---|---|
auto (default) |
Uses sync whenever any active output check can block or redact; otherwise async |
sync |
Buffer-and-release — the stream is buffered, checked, then released. Supports redaction and guarantees nothing violating is emitted, at the cost of time-to-first-token |
async |
Async-interrupt — tokens flow immediately; the check runs concurrently and terminates the stream on a violation. Block-only (no redaction), minimal latency, but some content may reach the client before the interrupt |
On an async-mode block, the client receives an error event followed by [DONE]:
data: {"error":{"type":"guardrail_block","message":"Output blocked by guardrail policy"}}data: [DONE]External providers
Section titled “External providers”The providers array configures external engines used by the checks:
{ "providers": [ { "provider": "presidio", "enabled": true, "endpoint": "http://presidio-analyzer:3030" }, { "provider": "lakera", "enabled": true, "api_key": "..." }, { "provider": "openai_moderation", "enabled": true, "api_key": "..." } ]}Supported provider values: lakera, openai_moderation, presidio, bedrock (requires config.guardrail_id), custom (requires endpoint). Presidio requires an endpoint — the trial bundle ships a presidio-analyzer service behind the presidio compose profile. Provider api_key values are encrypted at rest and never returned by GET.

When a check cannot run
Section titled “When a check cannot run”A check that errors or times out is a governance control that did not run. But “did not run” hides two very different situations, and treating them the same is how a guardrail becomes an outage generator:
- This request was unlucky — a slow packet, a GC pause, a single blip. Uncorrelated. Refusing costs one 503 that the client retries and nobody notices.
- This provider is down — every request fails at once. That is the outage worth worrying about.
So a failing check walks a resilience ladder before any failure policy applies:
| Rung | What happens | Audit tier |
|---|---|---|
| 1. Retry | Transient failures (timeout, 429, 5xx, connection refused) are retried within max_check_timeout_ms. Most blips die here and never reach a policy decision. |
retried |
| 2. Circuit breaker | After 5 consecutive failures the provider is declared down and the gateway stops calling it for 30s. This is what turns “unlucky” into a diagnosis. Keyed by provider + check, so one broken Presidio is one outage however many configs point at it. | — |
| 3. Local fallback | If the check has a local built-in, it answers instead. pii → the built-in regex bank, prompt_injection / jailbreak → the built-in detectors, toxic_content → the built-in profanity list. Degraded coverage beats none. |
fallback |
| 4. Policy | Only now does fail_open / fail_closed decide — by which point it is a rare, well-understood state rather than a coin flip on every request. |
policy |
Guardrail configs still fail closed at rung 4 (fail_open: false, the default): the
call is refused rather than passed through unchecked. Set fail_open: true for
log-and-allow. Per-check fail_open_overrides (keys: pii, prompt_injection,
jailbreak, toxic_content, banned_words, banned_patterns, secrets) override
either direction, so “log-and-allow everywhere except PII” is one config plus one
override.
Two deliberate exclusions:
- Local detectors get no breaker. A regex has no network to be down, and a breaker there would only add a way for a bug to disable a working check.
- Config errors never trip the breaker. A missing API key or a 401 is our misconfiguration, not the provider’s health. Opening a circuit for it would dress a config bug up as an outage, and the half-open probe could never succeed to close it.
A degraded check is never silent
Section titled “A degraded check is never silent”The ladder recovers coverage; it must never overstate it. Every rung below the first is recorded:
proxy_logs.guardrails_degraded—[{config, check, tier}]for checks that ran on a lesser rung.NULLon the happy path.proxy_logs.guardrails_skipped—[{config, check, reason}]for checks that did not run at all.
These are deliberately separate columns. Merging them would either count a lower-fidelity fallback pass as a full pass, or count a real inspection as none at all — and the Assurance Report has to tell those apart. A fallback did inspect the content and is genuine evidence; break-glass and policy did not, and never count toward coverage.
A skipped check also raises a high-severity system alert naming the config, the checks that did not run, and the cause. Alerts dedupe per config, so a broken provider produces one alert with a rising occurrence count rather than thousands — but two different broken configs produce two alerts, because one outage must never mask another.
Break-glass: declaring an outage
Section titled “Break-glass: declaring an outage”When a provider is genuinely down and availability must win, an operator declares a break-glass window rather than the gateway deciding for them.
curl -X POST http://localhost:5050/v1/admin/guardrails/break-glass \ -H "Authorization: Bearer $ADMIN_JWT" \ -d '{"reason": "Presidio 503s fleet-wide — INC-4471", "duration_minutes": 30}'While it is active, checks skip straight to log-and-allow — the gateway stops calling a provider it knows is down. The runtime effect is exactly what a hardcoded fail-open would produce. Three things make it acceptable instead of a liability:
- Attributed — the declaring admin is on the record, as is whoever ends it.
- Explained —
reasonis mandatory and non-trivial. An unexplained break-glass is indistinguishable from a misconfiguration when someone reviews it months later. - Expiring —
expires_atis NOT NULL and capped at 8 hours (one on-call shift). Over-long requests are clamped rather than rejected, because an operator mid-incident should get a valid window, not a validation error. Expiry is evaluated at read time, so a failed background job can never leave a tenant unguarded.
Renewing is an explicit deactivate-then-activate (a second activation returns 409) so
the audit trail shows two decisions rather than one window that silently grew. Ended
windows stay in history — the Assurance Report needs to name the period when checks did
not run.
Manage it at Governance → Guardrails, where an active window renders as a red banner with the countdown, the reason, and who declared it.
Creating a config
Section titled “Creating a config”
In the Admin UI: Guardrails → New config, pick surfaces, enable checks, assign resource groups. Via the API:
curl -X POST http://localhost:5050/v1/admin/guardrails/configs \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "default-safety", "description": "Baseline safety for all chat traffic", "enabled": true, "group_ids": ["9f2c..."], "surfaces": { "chat_input": true, "chat_output": true, "mcp_input": true }, "builtin": { "prompt_injection_enabled": true, "prompt_injection_action": "block", "jailbreak_enabled": true, "jailbreak_action": "block", "pii_enabled": true, "pii_action": "redact", "pii_provider": "presidio", "pii_threshold": 0.6, "pii_categories": ["email", "phone", "credit_card", "ssn"], "pii_surfaces": ["chat_input", "chat_output", "mcp_input"], "secrets_enabled": true, "secrets_action": "block", "secrets_categories": ["aws_access_key", "github_token", "openai_key", "private_key_pem", "jwt"] }, "providers": [ { "provider": "presidio", "enabled": true, "endpoint": "http://presidio-analyzer:3030" } ] }'Testing guardrails
Section titled “Testing guardrails”Test a config against sample content without sending real traffic — in the Admin UI (Guardrails → Test) or via the API:

curl -X POST http://localhost:5050/v1/admin/guardrails/test \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "content": "My card number is 4111 1111 1111 1111, please remember it", "surface": "chat_input", "config_id": "grc-..." }'{ "action": "redact", "blocked": false, "violations": [ { "type": "pii", "provider": "presidio", "confidence": 0.95, "action": "redact", "detail": "credit_card detected", "config_id": "grc-...", "config_name": "default-safety" } ], "duration_ms": 41.7, "surface": "chat_input", "direction": "input", "configs_evaluated": 1}Omit config_id to test what the tenant’s applicable configs would collectively do.
What a blocked request looks like
Section titled “What a blocked request looks like”Non-streaming chat completions return 403 in the caller’s native error shape:
{ "error": { "code": "guardrail_blocked", "message": "Prompt injection detected", "guardrail": "default-safety", "check": "prompt_injection", "direction": "input" }}(Anthropic-native callers get the same fields wrapped in Anthropic’s {"type": "error", ...} envelope.)
Every block writes exactly one audit row, visible in the proxy logs with guardrail_blocked = true, and increments the proxy_guardrail_blocks_total Prometheus metric labeled by surface and check.
Related pages
Section titled “Related pages”- Argument & semantic policies — structural tool-call rules and LLM-judge constraints, complementary to content guardrails
- Resource groups — how configs attach to your hierarchy
- Logs & audit trail — where guardrail decisions land

