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:

FieldDefault header(s)Static fallbackRename with
agent_idx-ogr-agent-idx-mse-consumeragent_id, then caller-<hash>agent_id_header
agent_typex-ogr-agent-typeagent_typeagent_type_header
agent_workspacex-ogr-agent-workspacex-mse-consumer-groupagent_workspaceagent_workspace_header
agent_ownerx-ogr-agent-owneragent_owneragent_owner_header
agent_userx-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 *_header replaces 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:

FieldAsserted byWhy
agent_idthe gatewaynames the party; a client that could set it would pick its own audit trail
agent_workspacethe gatewayselects the POLICY SET — the one field a caller must never choose
agent_ownerthe gatewaythe runtime backfills it once and never overwrites, so a forgery is permanent
agent_typethe clientwhich harness is running; only the client knows, and it selects nothing
agent_userthe clientchanges per request; only the client knows

⚠️ Strip the gateway-asserted headers at the edgex-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.spans into 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 tight timeout_ms plus 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