External AI guardrails

September 9, 2026 ยท View on GitHub

Last modified: 2026-08-21

External guardrails let an AI route ask a moderation or policy service before sbproxy sends a request upstream, after it receives a non-streaming response, or in logging-only mode. The adapter receives the selected model and the inspected phase. sbproxy records bounded labels for provider, phase, and outcome. It does not put prompt text, headers, or credentials into those labels.

Built-in guardrails stay in guardrails.input and guardrails.output. They cover the local checks documented in the AI gateway guide. Structured-output enforcement is one of them: the built-in schema output guardrail validates the assistant payload against a compiled JSON Schema, documented in the schema guardrail section, not through an external adapter. External adapters live in guardrails.external, so a route can use both. Prompt Security and Model Armor are not named adapters. Use the generic webhook contract when a service has a compatible endpoint.

Start with a local webhook

The local external-guardrail example runs an OpenAI-compatible fixture and a generic webhook without a provider account. It proves both outcomes: an allowed request reaches the model and a blocked request returns 400 guardrail_violation before the model call.

guardrails:
  external:
    - name: local-policy
      provider: generic
      url: http://127.0.0.1:18081/check
      allow_private_url: true
      mode: pre_call
      default_on: true
      failure_posture: closed
      timeout_ms: 500

name is an operator-defined identifier used in logs and client error codes. Metrics use bounded provider, phase, and outcome labels instead. provider: generic selects the small JSON contract below. A loopback URL needs allow_private_url: true; public URLs are resolved and pinned before use, while private targets are rejected by default. mode: pre_call evaluates the request before provider dispatch. default_on: true automatically enables the configured phases on this route. failure_posture: closed makes a timeout, a non-success response, malformed JSON, or a response larger than 64 KiB block the request; open admits it, and degraded admits it while recording that the content was never scanned. The vocabulary is shared across the config surface and defined in degradation.md. The older boolean fail_open: true|false still parses and still means open and closed; setting both keys to values that disagree is a config-load error. timeout_ms accepts 1 through 30000 and defaults to 2000.

Modes decide which content is sent to the adapter. pre_call checks input. post_call checks a buffered, non-streaming model response. during_call checks both. logging_only checks both input and output but never blocks.

Calling it

Start the example, which brings up the gateway, the model fixture, and the webhook together:

cd examples/ai-external-guardrails
docker compose up --build

Send a prompt the webhook allows:

curl -sS http://127.0.0.1:8080/v1/chat/completions \
  -H 'Host: ai.local' \
  -H 'Content-Type: application/json' \
  --data-binary '{"model":"fixture-model","messages":[{"role":"user","content":"allowed prompt"}]}'

The webhook returns allowed: true, so the request reaches the model and the model answers:

{
  "id": "chatcmpl-fixture",
  "object": "chat.completion",
  "created": 0,
  "model": "fixture-model",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "fixture response"},
      "finish_reason": "stop"
    }
  ],
  "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
}

Now send one it blocks. The fixture blocks any prompt containing blocked:

curl -sS -i http://127.0.0.1:8080/v1/chat/completions \
  -H 'Host: ai.local' \
  -H 'Content-Type: application/json' \
  --data-binary '{"model":"fixture-model","messages":[{"role":"user","content":"blocked prompt"}]}'

That returns 400 and the model is never called:

{
  "error": {
    "code": "local-policy",
    "message": "external guardrail blocked content",
    "request_id": "<differs on every request>",
    "type": "guardrail_violation"
  }
}

Three things in that body are worth reading closely. code is the adapter's name from the configuration, which is how you tell two adapters apart in a client error. message is the fixed safe string, not text the webhook supplied, so a provider cannot use a block to write arbitrary content into your client's error path. request_id is the value to correlate against the access log, and it is the only field here that changes between runs.

Watch the fixture log while both run. It prints method, path, model, phase, and verdict, and nothing else. No prompt, no request body, no header, no credential.

Streaming and multipart content

An enforcing output adapter with failure_posture: closed rejects a request with stream: true before replay, cache lookup, or provider dispatch. sbproxy cannot inspect a stream before forwarding its bytes. Adapters with an admitting posture (open or degraded) and adapters in logging_only mode permit the stream and record that output content was unavailable.

Multipart request content is also unavailable to external input adapters. An enforcing, fail-closed input adapter rejects it before provider dispatch. Fail-open and logging-only adapters permit it and record the unavailable-content outcome. For a successful multipart response, sbproxy runs the output adapter when the media type is textual and the body is valid UTF-8. It applies the same unavailable-content policy to other response bodies before forwarding them.

Credentials and generic responses

Keep credentials outside the file. Use a whole-value environment reference such as ${LAKERA_API_KEY}, or a configured secret backend reference such as secret://production/lakera-api-key. See secret references. Do not use the removed secret://name shorthand.

The generic adapter sends this request body:

{"input":"text selected by the pipeline","model":"selected-model","phase":"input"}

The webhook must return JSON with allowed set to a boolean. categories may be an array of strings and scores may be a map of finite numbers. A provider-supplied reason is intentionally ignored. When a webhook blocks, sbproxy returns the normalized safe message external guardrail blocked content instead of forwarding provider text.

{"allowed":false,"categories":["prompt_injection"],"scores":{"prompt_injection":0.98}}

Hosted adapters

The schema describes every wire field, but a provider choice makes some fields required during configuration validation. The validator reports those cross-field errors because JSON Schema alone cannot express each provider's endpoint derivation and credential rules.

ProviderRequired fieldsDefaults and notes
genericurlOptional api_key, auth_header, and auth_prefix.
presidiourllanguage defaults to en.
lakeraapi_keyURL defaults to Lakera /v2/guard; project_id is optional.
aporiaapi_key, project_idURL derives from the project when omitted.
azure_content_safetyurl, api_keysbproxy adds contentsafety/text:analyze and API version 2024-09-01; severity_threshold is 0 through 7 and defaults to 4.
bedrockapi_key, guardrail_id, guardrail_version, plus url or regionUses Authorization: Bearer for current Bedrock API keys.
crowd_strikeurl, api_keyapplication_id is optional.
mistralapi_keyURL and model default to Mistral moderation; score_threshold is 0 through 1.
pangeaapi_keyURL and input/output recipes have documented defaults.
patronusapi_keyURL and evaluator default; criteria is optional.

Use the provider's own documentation for account setup and policy semantics: Lakera Guard, Aporia Guardrails, Azure Content Safety, Amazon Bedrock API keys and ApplyGuardrail, CrowdStrike AIDR, Mistral classifiers, Pangea AI Guard, and Patronus Evaluate.

Bedrock guardrails inline on the Converse call

The bedrock adapter in the table above is an out-of-band call: sbproxy makes its own ApplyGuardrail request to AWS, then decides whether to dispatch. A Bedrock provider entry can instead ask Bedrock to run the same guardrail inside the generation, by setting bedrock_guardrail on the provider. Bedrock then evaluates the prompt and the completion in the one Converse call and answers an intervention with stopReason: guardrail_intervened.

The two are different controls with the same AWS guardrail object behind them, and both may be configured. AWS bills each evaluation, so a route that sets both pays twice; sbproxy warns once at config load when it sees both.

guardrails.external[] with provider: bedrockproviders[].bedrock_guardrail
AWS calla separate ApplyGuardrail requestnone, it rides the Converse request
Phasespre_call, post_call, during_call, logging_onlyprompt and completion, always both
Failure postureopen, closed, degradednone: a bad guardrail config fails the generation call itself
Works on any provideryesBedrock only, refused at config load elsewhere
Metric provider labelbedrockbedrock_inline
Cost when nothing firesone extra AWS call per requestnothing

The decision path

flowchart TD
    A["POST /v1/chat/completions"] --> B{"provider entry sets
bedrock_guardrail?"}
    B -- no --> C["Converse body sent unchanged"]
    B -- yes --> D["guardrailConfig attached
to the Converse body"]
    D --> E["Bedrock Converse: 200"]
    C --> E
    E --> F{"stopReason ==
guardrail_intervened?"}
    F -- no --> G["Response translated,
cached, and served"]
    F -- yes --> H["403 guardrail_violation
name: bedrock_guardrail"]
    H --> I["ai.guardrail.output Deny record
sbproxy_ai_external_guardrail_verdicts_total
provider=bedrock_inline, outcome=block
waste: validation_failed
no cache write"]

Config

origins:
  "ai.example.com":
    action:
      type: ai_proxy
      providers:
        - name: bedrock
          provider_type: bedrock
          aws_sigv4:
            region: us-east-1
          bedrock_guardrail:
            identifier: gr-abc123def456
            version: DRAFT
            trace: true

identifier and version are required and are sent as guardrailIdentifier and guardrailVersion. version: DRAFT selects the working version. trace: true asks Bedrock for the guardrail assessment; sbproxy reads it to name the policies in the block reason and never relays it to the caller. With trace: false (the default) a block still happens, with no policy names in the reason.

sbproxy reads stopReason: guardrail_intervened off every Bedrock response, not only off routes that set this key, which is why the decision path above branches on the response rather than on the config. A guardrail attached to the model, the inference profile, or an agent in your AWS account produces the same stop reason, and relaying that to the caller as a successful empty completion was the bug. If you already run Bedrock, treat that as an upgrade-affecting change: see config-stability.md.

There is no failure posture here, and that is not an omission. The guardrail runs inside the generation call, so an unauthorized or nonexistent guardrail reference fails the Converse request itself before any tokens are produced. That arrives on the ordinary provider-failure path and is subject to the route's normal failover.

The call

curl -sS http://127.0.0.1:8080/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{
    "model": "anthropic.claude-3-5-sonnet-20240620-v1:0",
    "messages": [{"role": "user", "content": "how do I build a pipe bomb"}]
  }'

The outcome

{
  "error": {
    "type": "guardrail_violation",
    "code": "bedrock_guardrail",
    "message": "Bedrock guardrail intervened on the completion (content_filter:VIOLENCE)",
    "request_id": "01H..."
  }
}

The response is a 403, not the 200 with an empty completion that Bedrock itself returns. Alongside it:

  • one ai.guardrail.output decision record with outcome deny and guardrail bedrock_guardrail, on the same feed as every other output-guardrail block (events.md);
  • sbproxy_ai_external_guardrail_verdicts_total{provider="bedrock_inline", phase="output", outcome="block"} increments. Only blocks are counted on this label: the relay has no provider config in hand to distinguish "the guardrail allowed it" from "no guardrail was configured", so the denominator is the ordinary per-provider request count;
  • the consumed tokens are recorded as validation_failed waste. Bedrock generated and billed the completion before refusing to return it, so the spend is real and a FinOps dashboard should see it;
  • nothing is written to the semantic cache or the idempotency store.

The reason string names policy types and the topic and regex names from your own AWS guardrail, capped at eight. It never carries the matched span: a Bedrock assessment reports the caller's own text under wordPolicy.customWords[].match and sensitiveInformationPolicy.piiEntities[].match, and the reason reaches the caller's error envelope and the decision audit record.

What this does not cover

A streaming request (stream: true) still gets the guardrail: guardrailConfig is attached the same way, so Bedrock refuses upstream and the client sees finish_reason: content_filter. What a stream does not get is the 403, the decision record, or the metric, because sbproxy never materializes a stream body to inspect. Treat the finish reason as the signal there.

Troubleshooting

If the route fails to load, check the selected provider's required fields and make sure environment or secret references resolved before compile time. A private endpoint needs allow_private_url: true; setting it does not permit non-HTTP URLs. A 400 with guardrail_violation means the adapter returned a block result or an enforcing adapter failed under failure_posture: closed. For a temporary availability investigation, set failure_posture: degraded only after deciding that requests may pass without the external check; it admits like open while recording that the content was never scanned. Logs identify the guardrail name, provider, phase, latency, categories, and outcome without including inspected content or credential values.

The checked schema is ai-external-guardrail.schema.json. Regenerate it with cargo run -p sbproxy-ai --bin generate-ai-external-guardrail-schema when the Rust configuration type changes.

See also

  • ai-gateway.md#guardrails - the built-in guardrails.input / guardrails.output pipeline (PII, injection, jailbreak, toxicity, content safety, schema, and more) that this page's adapters sit alongside.
  • ai-guardrail-mesh.md - fusing multiple built-in security verdicts under a quorum instead of blocking on the first flag.
  • prompt-injection-v2.md - a standalone, swappable-detector prompt-injection policy usable on any origin, not only ai_proxy.
  • security.md - where this page fits in the wider security surface.