Policy guardrails
Enforce Agent Engine model, tool, token, and spend rules on model and MCP traffic in Orca AI Gateway.
A policy guardrail governs an action or usage boundary with an allow, ask, or deny
decision. The gateway loads the rules from the Agent Engine registry, where they are defined as
guardrails, or from a local file. They are separate from the
payload guardrails configured under plugins.guardrails.
| Family | Configured under | Governs |
|---|---|---|
| Policy guardrails | guardrail_source, guardrail_state, guardrails_policy | Model choice, tool calls, token use, and spend. |
| Payload guardrails | plugins.guardrails[] | Request and response content, including PII redaction. |
Start with the Agent Engine integration. The
session JWT mapping must supply verified org_id, workspace_id, and session_id scope values,
because the registry scopes each bundle to all three; agent_id is optional. Agent Engine routes MCP calls through the Gateway; its model calls reach the Gateway
in colocated mode or when separate mode selects gateway egress. A separate session calls its
provider directly unless the deployment default or session metadata selects the gateway.
Connect the Agent Engine registry
Use the registry internal-listener URL and workload token when Agent Engine prepares the effective policy for each authenticated session. This example uses Redis for stateful policy evaluation:
guardrail_source:
name: registry-policy
kind: registry
base_url: http://orca-registry:8081
bearer_token_file: /var/run/secrets/orca/registry/token
on_backend_error: deny
guardrail_state:
name: policy-state
kind: redis
url: redis://redis:6379/1
key_prefix: "orca:guardrails:"
flush_interval_ms: 2000
guardrails_policy:
mode: enforce
phases: [llm_request, llm_response, tool_call, tool_result]
failure_mode:
llm_request: deny
on_missing: deny
on_expired: deny
on_unsupported_guardrail: deny
refresh_interval_secs: 30
max_staleness_secs: 900The source loads a session's bundle on the session's first authenticated request. base_url is the
registry internal-listener root, and the gateway rereads the bearer-token file each time it fetches
a bundle. Map the registry session claims into verified principal attributes before enabling policy.
See Connect Agent Engine for a complete identity
configuration.
The source calls /internal/v1/guardrails/effective, so a public Workspace API key cannot
authenticate this connection. Agent Engine integration also needs a separate public Workspace
credential for Registry model pricing and
a workload token for the Registry usage sink.
If a refresh fails, the gateway keeps using the session's last valid bundle. For a session with no
bundle yet, on_backend_error decides. With deny, and in practice with last_known, the session
gets no bundle and on_missing applies: the source reuses a cached bundle only while it is younger
than cache_ttl_secs (900 seconds by default), so last_known has nothing older to fall back to.
With allow, the gateway substitutes an allow-all bundle that expires after 60 seconds, after which
on_expired applies once max_staleness_secs has passed.
A changed rule reaches a running session when the gateway fetches a newer bundle. The registry
source caches each session's bundle for up to cache_ttl_secs, so a change can take that long to
apply; start a new session to validate a change immediately. The gateway does not need a restart.
Choose phases and mode
The gateway wires exactly four policy phases:
| Phase | Boundary | Enforced result |
|---|---|---|
llm_request | Before a model request reaches a provider | A deny returns 403 policy_guardrail_denied. |
llm_response | After model usage is known | The decision is recorded; the completed response is not withdrawn. |
tool_call | Before an MCP tools/call reaches the server | A deny rejects the whole JSON-RPC batch that contains the call. |
tool_result | Before an MCP tool result reaches the caller, including each streamed event | A deny replaces that result with a JSON-RPC error. |
Harness-only phases named request and response fail configuration validation when policy is
active. Registry budget rules declared on request are evaluated at llm_request instead.
mode: enforce applies the boundary behavior in the table and writes state. mode: observe
evaluates policy and records the would-be decision without blocking traffic. It skips the state
updates that rules make, but it still seeds state and records usage counters in the state store.
mode: off disables the runtime and does not require a source.
The gateway has no approval round trip. Any policy result of ask is therefore resolved to deny,
and cost-budget ask_thresholds_usd never fire here.
Only failure_mode.llm_request is read from the failure-mode map; additional phase keys are
accepted but have no effect. Keep harness enforcement on alongside gateway policy: the gateway
cannot pause an MCP call for the Agent Engine ask confirmation exchange, and its phases do not
include the harness's request phase.
Use supported rules
The 0.4.3 runtime executes five of the builtins that the Agent Engine registry can deliver:
| Builtin | Purpose |
|---|---|
block_tools | Deny tool names matching tools. |
cost_budget | Limit session cost with max_cost_usd. |
user_daily_cost_budget | Limit daily cost for a stable user or owner identity. |
subagent_cost_budget | Limit cost attributed to one dispatched subagent. |
token_budget | Limit session tokens with max_total_tokens. |
Four more builtins run only from a local bundle, because the registry catalog does not offer them:
| Builtin | Purpose |
|---|---|
model_allowlist | Allow model IDs matching allowed_models, compared with the model the client requested. An empty list denies every request. A rule that sets allowed_providers counts as unsupported. |
tool_allowlist | Allow tool names matching allowed_tools. An empty list allows every tool. |
rpm / requests_per_minute | Limit requests per subject in each UTC minute. |
tpm / tokens_per_minute | Limit tokens per subject in each UTC minute. |
The gateway sees an MCP tool as mcp__<backend>__<tool>, where <backend> is the request's
X-Orca-Backend value. Tool patterns accept * and ? globs.
A rule the gateway cannot run affects every event at its phase, not only the events it would
match. Registry bundles can contain 17 other builtins and CEL expression rules, which the
gateway does not execute. With the default on_unsupported_guardrail: deny, one such rule with a
tool_call phase makes the gateway deny every MCP tool call in the sessions it applies to, and
deny_pii_in_llm_request with its default llm_request phase makes it deny every model request.
Unsupported tool_result rules pass. skip_with_alert skips unsupported rules instead, without a
log entry or metric, and leaves them to the harness. Check the
catalog before you add a Workspace or
organization rule to a registry that feeds this gateway.
Cost guardrails need the spend-control price model to evaluate
the next model request. In enforce mode, the gateway reserves an estimate at llm_request: the
request's input tokens plus its maximum output tokens, priced at the most expensive destination of a
fallback chain, or at the selected destination of other routes. It settles the difference when the
response reports usage; a failed dispatch leaves the estimate counted. With the default
failure_mode, a model with no price passes a cost cap, and the rule's own on_unpriced parameter
is ignored; set failure_mode.llm_request: deny to block unpriced models instead.
user_daily_cost_budget needs a stable subject: a verified subject_id, user_id, or owner_id,
or a principal that is neither an agent nor the session itself. Without one, the rule counts as
unsupported, so under the default on_unsupported_guardrail: deny every model request it applies to
is denied.
When a cost budget with expensive_models denies a request for a matching model, the gateway marks
the request with budget.state=exhausted and routes it once more, so a route can match on that
marker and choose a cheaper destination. If routing selects a different destination, the gateway
authorizes and evaluates it again and records a policy.guardrail.budget_downgraded audit event;
otherwise it returns the original 403.
Manage registry rules
Create, attach, and retire registry rules with ork guardrails, the API, or an SDK, as described in
Guardrails. Rules with workspace or organization scope apply
to every session in their scope; rules with explicit scope apply only to agents and sessions that
list them. Registry rules use Agent Engine phase names: a budget authored on request is the
gateway's llm_request.
Validate policy with an agent session
This minimal workflow creates three explicit guardrails and binds one to each test session. It
checks a normal request, a cost-budget decision, and a token-budget decision without applying the
rules to unrelated sessions in the Workspace. Configure the ork CLI as described in the
CLI overview first.
Create the three guardrails and capture their ids:
create_guardrail() {
local scenario="$1"
local builtin="$2"
local params="$3"
local response
response=$(ork guardrails create \
--config-json "$(jq -cn \
--arg name "guardrail-demo-$scenario" \
--arg builtin "$builtin" \
--argjson params "$params" \
'{name:$name,scope:"explicit",phases:["request"],rule:{kind:"builtin",builtin:$builtin,params:$params}}')" \
--output json)
jq -r '.id' <<< "$response"
}
ALLOW_GUARDRAIL_ID=$(create_guardrail allow cost_budget '{"max_cost_usd":10}')
COST_GUARDRAIL_ID=$(create_guardrail cost-deny cost_budget '{"max_cost_usd":0.000001}')
TOKEN_GUARDRAIL_ID=$(create_guardrail token-deny token_budget '{"max_total_tokens":1}')Create one environment and agent for the test:
environment=$(ork agent environments create \
--name "guardrail-demo" \
--config-type cloud \
--output json)
ENVIRONMENT_ID=$(jq -r '.id' <<< "$environment")
agent=$(ork agent create \
--name "guardrail-demo" \
--model-json '{"provider":"anthropic","id":"claude-sonnet-4-6"}' \
--system "Answer in one short sentence. Do not use tools." \
--output json)
AGENT_ID=$(jq -r '.id' <<< "$agent")Create a session for each rule. agent_with_overrides.guardrail_ids binds the explicit guardrail.
The sessions do not set a per-session orca_llm_egress override, so a deployment whose default
model egress is the gateway can use the resulting usage records to verify that default. See
Route model calls through the gateway by default
for the self-hosted Helm configuration.
create_session() {
local scenario="$1"
local guardrail_id="$2"
local agent_ref
local session
agent_ref=$(jq -cn \
--arg id "$AGENT_ID" \
--arg guardrail "$guardrail_id" \
'{type:"agent_with_overrides",id:$id,guardrail_ids:[$guardrail]}')
session=$(ork agent sessions create \
--environment-id "$ENVIRONMENT_ID" \
--agent-json "$agent_ref" \
--title "guardrail-demo-$scenario" \
--metadata "demo_scenario=$scenario" \
--initial-event-json '{"type":"user.message","content":[{"type":"text","text":"Introduce yourself in one short sentence."}]}' \
--output json)
jq -r '.id' <<< "$session"
}
ALLOW_SESSION_ID=$(create_session allow "$ALLOW_GUARDRAIL_ID")
COST_SESSION_ID=$(create_session cost-deny "$COST_GUARDRAIL_ID")
TOKEN_SESSION_ID=$(create_session token-deny "$TOKEN_GUARDRAIL_ID")Poll each session until it is no longer running, then inspect its events:
for session_id in "$ALLOW_SESSION_ID" "$COST_SESSION_ID" "$TOKEN_SESSION_ID"; do
while :; do
session=$(ork agent sessions get "$session_id" --output json)
status=$(jq -r '.status' <<< "$session")
[[ "$status" != "running" ]] && break
sleep 3
done
ork agent sessions events list \
--session "$session_id" \
--limit 100 \
--output json
doneIn observe mode, all three provider requests complete. The allow session has an allow request
decision, while the cost and token sessions have deny request decisions without being blocked. In
enforce mode, the two deny decisions stop before provider dispatch.
For the observe run, filter the configured usage and audit backends by the three session ids. Each
session should have a usage record and a policy.guardrail.llm_request audit record. In enforce
mode, a denied request never reaches a provider, so it has an audit record but no usage record. For
Kafka sinks, see Audit and usage records for topic naming and
decoding.
Archive the temporary resources after validation:
for session_id in "$ALLOW_SESSION_ID" "$COST_SESSION_ID" "$TOKEN_SESSION_ID"; do
ork agent sessions archive "$session_id"
done
ork agent archive "$AGENT_ID"
ork agent environments archive "$ENVIRONMENT_ID"
for guardrail_id in "$ALLOW_GUARDRAIL_ID" "$COST_GUARDRAIL_ID" "$TOKEN_GUARDRAIL_ID"; do
ork guardrails archive "$guardrail_id" --output json
doneStore policy state
Stateful rules need guardrail_state. Without it, every MCP tool call that a stateful rule applies
to is denied, and model requests follow failure_mode.llm_request. Use kind: memory for a
single-replica development setup:
guardrail_state:
name: local-policy-state
kind: memoryUse kind: redis when replicas must share session, subject-day, and subject-minute counters. When
the source is the registry, the Redis store also batches state updates back to the registry. The
gateway logs a failed write-back and does not retry it, and drops new batches when the write-back
queue is full; neither rejects the current request. Monitor the gateway logs for registry write-back
failures.
Use a local bundle for development
A file source can load an unsigned bundle only when you opt in explicitly:
guardrail_source:
name: local-policy
kind: file
path: /etc/orca-gateway/policy.json
high_water_path: /var/lib/orca-gateway/policy.high-water
verification:
allow_unsigned: true
guardrail_state:
name: local-policy-state
kind: memory
guardrails_policy:
mode: observe
phases: [llm_request, llm_response, tool_call, tool_result]The file source keeps rollback state in high_water_path, with lock and marker files beside it.
When you omit the field, it writes <path>.high-water and those files beside the bundle, so the
bundle's directory must be writable; a read-only ConfigMap mount fails. A file bundle must be unexpired and hold only
workspace and organization rules. The gateway checks it at startup and keeps the last good bundle
when a later reload fails. Increase the bundle's generation with every edit: the gateway ignores a
reloaded bundle whose generation is not higher, and fails to start on one that is lower, or equal
with different content.
Use unsigned files only in a trusted development environment. Signed file sources require an
ES256 public key, a stable source_id, and persistent high_water_path rollback state. The
current CLI cannot bootstrap a fresh signed high-water state, so a new or lost state volume fails
closed.
Denial responses
A denied model request returns 403. denied_by names the guardrail, or ask_resolution when an
ask was resolved to deny. It is absent when the denial comes from policy availability - a
missing or expired bundle, a state store failure, or a rule the gateway cannot run - and the
message then reads guardrail policy unavailable at ... boundary:
{
"error": {
"code": "policy_guardrail_denied",
"message": "policy guardrail denied: This session has reached its $10.00 budget (spent $10.02). Switch to a less expensive model to continue.",
"denied_by": "grd_01H8..."
}
}A denied MCP call returns HTTP 200 with a JSON-RPC error for each call in the batch:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32001,
"message": "policy guardrail denied: Tool mcp__github__create_issue is blocked.",
"data": { "code": "policy_guardrail_denied" }
}
}Audit and metrics
With an audit sink configured, each policy decision emits an audit event whose action is
policy.guardrail.llm_request, policy.guardrail.llm_response, policy.guardrail.tool_call, or
policy.guardrail.tool_result, with the phase, whether it was enforced, and the bundle generation.
Usage records carry the most restrictive decision in guardrail_verdict, guardrail_denied_by, and
guardrail_enforced, plus a guardrail_decisions list.
The gateway exports orca_gw_guardrail_decisions_total{phase,verdict,enforced} and
orca_gw_guardrail_unavailable_total{phase}. See Telemetry for
scraping.
Inspect effective policy
The admin plane exposes GET /admin/v1/guardrails/effective?session_id=..., which returns the bundle
already cached for a verified session. It returns 404 when no single cached bundle matches the
session, 400 without session_id, and 503 when mode is off. It does not fetch a bundle, and
its guardrail_state holds the bundle's seed values rather than live counters. Keep the admin
listener on a trusted network; see the HTTP API reference.
Related
Payload guardrails
Inspect, sanitize, or block request and response content.
Spend controls
Supply prices for cost-budget admission and usage settlement.
Identity
Map session and subject claims into verified attributes.
Agent Engine guardrails
Create rules, attach explicit rules, and set cost budgets.
Configuration reference
Review policy phases, availability behavior, and state settings.