Skip to content

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.

Guardrail configs list

Guardrails run on every surface the gateway carries: LLM input and output, streaming, embeddings, image, audio, batch, MCP tool calls, Skills and A2A. Six checks are available — PII, secrets, prompt injection, jailbreak, toxic content and banned words — and each can be run by the built-in in-process detector or delegated to Microsoft Presidio, AWS Bedrock, Lakera Guard, OpenAI moderation or your own endpoint. Where several run, the most restrictive action wins. If a provider is slow or down, Brutor retries, trips a circuit breaker, falls back to the built-in detector, and finally fails closed.

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

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.

This is the most common source of “my guardrail isn’t firing” confusion, so read this twice.

Surfaces tab on a guardrail config

A check runs on a surface only where BOTH layers enable it:

  1. Config-level surfaces map — turns each surface on/off for the whole config.
  2. Per-check *_surfaces list (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>_surfaces

Example. 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.

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.

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]

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.

PII check backed by Microsoft Presidio

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.

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. NULL on 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.

When a provider is genuinely down and availability must win, an operator declares a break-glass window rather than the gateway deciding for them.

Terminal window
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.
  • Explainedreason is mandatory and non-trivial. An unexplained break-glass is indistinguishable from a misconfiguration when someone reviews it months later.
  • Expiringexpires_at is 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.

Guardrail config editor — checks tab

In the Admin UI: Guardrails → New config, pick surfaces, enable checks, assign resource groups. Via the API:

Terminal window
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" }
]
}'

Test a config against sample content without sending real traffic — in the Admin UI (Guardrails → Test) or via the API:

Guardrail test panel

Terminal window
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.

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.