Gateway integration
The quickstart instruments one agent whose code you own. A gateway integration guards every agent behind it and changes none of their code: the gateway already holds the raw provider request and the raw provider response, which are exactly the two refusable moments the protocol cares about.
client ──▶ gateway ──▶ OGR plugin ──▶ runtime POST /v1/evaluate
(agents) │ (+ /v1/heartbeat)
▼
LLM upstream
Same recipe, different vantage: mint a
step_id per proxied model call, evaluate the raw request before it goes
upstream, evaluate the raw response before it reaches the caller, hold the
tail on streams. The one thing that genuinely differs is identity: an
agent asserts its own five-tuple from config; a gateway reads its
authenticated caller's off the request. That is what the rest of this page
is about.
A gateway is something you operate — it is not an OGR-hosted service.
The reference plugin: Higress
integrations/gateway/higress
is the v0.8 reference gateway integration — a Go/WASM filter, published as an
OCI artifact, called OpenGuardrails Runtime in the Higress console.
# WasmPlugin, priority 200 — BELOW key-auth (310), which writes the consumer header
url: oci://docker.io/openguardrails/higress:3.1.0
defaultConfig:
runtime_cluster: "outbound|443||ogr.example.com" # the Envoy cluster
runtime_base_url: "https://ogr.example.com" # used for the Host header
api_key: "ogr_..." # organization API key
mode: observe # enforce when you're ready
⚠️ Priority matters. The plugin must run after the authenticator, or it
sees no caller at all — and before nothing else it needs. In Higress that
is priority 200 against key-auth's 310.
The identity five-tuple, as headers
Nothing has to be configured for identity to work. Each of the five fields is
read from a request header — a chain for agent_id and agent_workspace,
first non-empty wins — falling back to a static config value, and agent_id
falls back once more to a credential fingerprint:
| Field | Default header(s) | Static fallback | Rename with |
|---|---|---|---|
agent_id | x-ogr-agent-id → x-mse-consumer | agent_id, then caller-<hash> | agent_id_header |
agent_type | x-ogr-agent-type | agent_type | agent_type_header |
agent_workspace | x-ogr-agent-workspace → x-mse-consumer-group | agent_workspace | agent_workspace_header |
agent_owner | x-ogr-agent-owner | agent_owner | agent_owner_header |
agent_user | x-ogr-agent-user | (none — per-session by nature) | agent_user_header |
The x-ogr-* names are OGR's own spelling. The x-mse-* fallbacks are the
spellings existing gateway deployments already carry, and the two arrive very
differently: x-mse-consumer is written by the authenticator (Higress
key-auth, on every authenticated request), while x-mse-consumer-group
is admin-configured — no authenticator writes it; an operator decides it,
by assigning consumers to groups in the MSE console or by a header-injection
rule on a self-hosted route that runs early enough for the filter to see it.
Three things to know before you configure anything:
- Configuring a
*_headerreplaces the whole chain, it does not extend it. Name one header and that is the only one read. - A field that resolves to nothing is sent as the empty string — the protocol's explicit "no assertion", never an error. A gateway that reads no identity at all still reports fully attributable traffic, because the API key is the identity floor.
- There is no static
agent_user. A constant user is already what the floor gives you; the field exists to change per request.
Static values are for a route that fronts exactly one agent:
agent_id: "invoice-bot"
agent_type: "my-harness"
agent_workspace: "finance-agents"
agent_owner: "payments-team"
Who gets to assert what
The five fields split in two, and the split is the security model:
| Field | Asserted by | Why |
|---|---|---|
agent_id | the gateway | names the party; a client that could set it would pick its own audit trail |
agent_workspace | the gateway | selects the POLICY SET — the one field a caller must never choose |
agent_owner | the gateway | the runtime backfills it once and never overwrites, so a forgery is permanent |
agent_type | the client | which harness is running; only the client knows, and it selects nothing |
agent_user | the client | changes per request; only the client knows |
⚠️ Strip the gateway-asserted headers at the edge — x-ogr-agent-id,
x-ogr-agent-workspace, x-ogr-agent-owner, plus any x-mse-* spelling you
honour — and strip them before the authenticator runs. The plugin cannot
tell a header your gateway wrote from one a client sent, and key-auth does
not overwrite a client-supplied consumer header: a valid credential plus
a forged x-mse-consumer is attributed to the forgery, and a forged
workspace changes which policy set judges the traffic.
⚠️ "Before" is a phase question, not a priority one. Istio orders WASM
filters by phase first (AUTHN before UNSPECIFIED_PHASE) and by priority
only within a phase — so a stripper at phase: UNSPECIFIED_PHASE, priority: 400 runs after key-auth at phase: AUTHN, priority: 310 and deletes the
authenticated header it exists to protect. Put the stripper at phase: AUTHN
with a priority above the authenticator's: strip, then authenticate, then
report. Then verify it on your own deployment.
When nothing names the agent
A route that carries no consumer header and configures no static agent_id
still reports an agent: the plugin fingerprints the credential the client
presented (Authorization: Bearer …, x-api-key, or api-key — first
non-empty wins, configurable via caller_key_headers) and sends
agent_id: "caller-<12 hex of sha256>".
This exists because the alternative was one agent per gateway. With an
empty agent_id the runtime falls back to the credential it can see — the
gateway's own OGR API key — and since one gateway has one key, every consumer
behind it collapses into a single inventory row: one policy resolution, one
owner for traffic that had many, one blast radius for every "move this agent"
click. Different callers hold different keys, so fingerprinting theirs is the
true statement where the gateway's key was a false one. The credential never
leaves the gateway; only its truncated hash does.
⚠️ It is a floor, not a substitute for authentication. It says "these
requests came from one credential", never whose. A credential shared by a
team is one caller here, and rotating a credential mints a new agent row —
both removed by authenticating properly. The caller- prefix is there so
nobody reads a fingerprint as an authenticated identity. Set
caller_fallback: false to switch it off.
Observe first, then enforce
mode: observe # report only: never pauses a request, never touches a body
mode: enforce # evaluate each step half before it proceeds, honour the verdict
Both modes compute the same events; only the dispatch differs — observe fires
/v1/evaluate and discards the verdict unread, enforce awaits it. Observe
still detects, because evaluate is the observation channel: the console
fills with findings while the gateway stays a mirror. Watch for a week, then
flip the switch; rolling back is flipping it back, not redeploying.
fail_mode is open by default — an unanswered evaluate proceeds and is
counted unchecked. closed refuses when the runtime is unreachable,
answers garbage, or reports unjudged
paths.
What the plugin does that your agent code cannot
- Streams: it forwards the reply as it arrives but withholds the last
stream_tail_chars(default 200) until the end-of-stream verdict — hold the tail, judge once. - Redaction: it splices the verdict's
modifications.spansinto the body before forwarding, and restores the placeholders in the reply (buffered and streamed). The runtime never sees or returns plaintext. - Refusals in the caller's own protocol: Chat Completions
(
openai.chat), Responses (openai.responses) and Anthropic Messages (anthropic.messages) each get their own shape. - Heartbeat: every 30s, with the counters that make coverage loss
visible. Alert on
unchecked— traffic that passed with no verdict behind it, which is what a tighttimeout_msplus fail-open produces and is invisible in every other signal.
Configuration reference
The full key-by-key table, the timeout budget ordering, the mirror runtime, and the local-lab instructions live in the plugin's README. The cross-gateway normative version of the header conventions on this page is Runtime API § at a gateway.
Next
- Quickstart — the same recipe from the agent side.
- The GuardEvent object — the nine fields a gateway fills.
- Plugins — the other integration points.