ai_analysis rule sends a statement to a language model and denies on the risk that comes back. It runs on every lane: SQL on a postgres or mssql listener, request bodies on an http one.
It is the only rule type that leaves the process, costs money per statement and can take a second, so most of its design is about not doing those things. Read the cost controls before you enable it anywhere real.
This runs entirely in the sidecar. There is no gateway call and no control plane: the Sidecar holds the provider credential and reads one YAML file, same as every other feature here.
A working config
config.yaml
Where it runs in the chain
Evaluators compose in ascending order of cost, and the chain stops at the first denial:DELETE that a type: operation rule already refuses never reaches a model. That ordering is fixed.
The trailing decide-phase call appears only on a lane where a risk level or a local rule defers. The gate phase puts a second decision in front of the analyzer as well.
The three cost controls
An ORM issues the same statement shape thousands of times in one session. Without these, that is thousands of API calls.1
trigger narrows what is classified
Only statements naming these operations, tables or resources are sent. Everything else allows for free.An empty trigger classifies nothing and is a startup error. The failure mode of the opposite default is an invoice. A lane running the gate is the exception: there the policy decides what gets classified, and an empty trigger is how an operator says so.
2
The cache keys on the statement shape
WHERE id = 1 and WHERE id = 2 are one verdict: literals are stripped from SQL, and HTTP resources are already normalized by the codec, so /users/12345/orders and /users/67890/orders share an entry.This is also more correct than caching on bytes. The shape is what is risky, not the parameter.3
max_calls is a backstop
A process-lifetime budget. Past it, statements fall through to the local rules and OPA, the same outcome as a lane with no analyzer.
Actions
A risk level you do not name defaults to
allow, so you opt into blocking a tier by writing it down.
A blocked statement reaches the user the same way every other denial does:
defer hands the verdict to Rego
high: block decides the same way for everyone who touches the lane. high: defer makes the risk level one input among the actor, the hour and the table, and lets the Rego your InfoSec team already owns weigh them:
ai_analysis source:
status before reading values. An absent risk_level means “found nothing”, “never ran”, “budget spent” and “provider down” all at once, and only the status tells them apart. Policy Rules has the full status vocabulary.
defer on a lane with no policy.opa.url is refused at startup. A finding nobody reads forwards every statement while looking like enforcement.
The gate phase
trigger is a static filter written in YAML. A lane wanting “classify an UPDATE, but only outside business hours, and only against a table the policy calls sensitive” cannot say that in a trigger, and widening the trigger until it can pays for every statement in between.
policy.opa.gate: true adds an OPA decision before the analyzer runs, so the policy answers whether the call is worth making:
input.phase, so a policy ignoring the field answers both identically and turning the gate on costs one round trip. The gate answers with its allow/deny plus a request map keyed by producer source:
true runs the analyzer where its own trigger would have skipped, false vetoes a run the trigger would have made, and an absent key leaves the trigger in charge.
An undefined gate decision allows and requests nothing, even under
fail_open: false. A gate is an optimization over a policy someone already wrote, so reading its absence as a denial would block every statement on the lane until the Rego author writes a second rule nobody asked for. The decide phase keeps the fail-closed reading of undefined.gate: true on a lane with no ai_analysis rule is refused at startup: a round trip per statement that gates nothing.
Requests only
The analyzer classifiesFromClient statements and ignores responses. By the time a response comes back the write has already run, so a verdict cannot prevent anything. Read-side exposure is masking’s job, which costs less and already runs.
Why this one fails open
Every other evaluator in the Sidecar fails closed. This one defaults tofail_open: true, and the difference is what it depends on.
OPA is a service you run, usually on the same host. A language model is a third-party API over the public internet. Fail closed there and a vendor outage refuses every UPDATE on the lane: you have turned “we could not score this statement” into “the database is down”, which is a bigger incident than the one you were guarding against.
The verdict still carries the error, so it reaches the audit trail and /stats counts it. The local rules and OPA both ran and both allowed, so a lane whose analyzer is down keeps every guardrail except the paid one.
Set
fail_open: false where the classification is a compliance requirement, and accept that a provider outage then stops traffic.What leaves the process
send decides, using the same detector that powers masking:
redacted and refuse are refused at startup without a pii section, because a mode that cannot do what its name says is worse than one that is off.
HTTP headers never reach the model, even ones a lane allowlists for policy. An allowlist that is safe for a local rule is not safe to hand a third party.
Writing your own prompt
Risk depends on what you are protecting, so the guidance is replaceable at two levels. Protocol- and lane-specific wording belongs on a rule, which wins:The part you cannot replace
A prompt replaces the guidance. Two instructions are appended after whatever you write and cannot be removed:Report the verdict by calling exactly one risk tool
Report the verdict by calling exactly one risk tool
The risk level is which tool the model chose, which makes it a three-value enum instead of a parsing problem. Without this instruction the model answers in prose, nothing maps to a level, and every statement fails classification. Under
fail_open: true that allows everything.Never quote a literal value from the statement
Never quote a literal value from the statement
The verdict is written to an audit record, and audit redaction covers statement text but not verdict metadata. A title repeating the identifier it objected to has published that identifier, through a channel that bypasses your
redact_statements setting.The HTTP lane needs capture_body
The HTTP codec exposes nothing by default: no bodies, no headers. Without a body the analyzer sees POST /orders and nothing else, which tells a model nothing.
ai_analysis rule on an HTTP lane without capture_body: true is refused at startup, because it could never classify anything.
A request that carries no body is still skipped at runtime once capture is on, and that stays intentional: paying for a verdict on POST /orders with no payload is the cost this design avoids. The startup check only asserts that the proxy could capture a body.
Providers
- Google Vertex
- Anthropic
- OpenAI
--validate mints one token, so a bad key, a missing roles/aiplatform.user binding or a skewed clock fails the config check rather than the first risky statement.Set region: global or a multi-region endpoint. A single-region endpoint is an availability risk for something sitting on a database hot path.The credential
The config holds a path, never the key itself. Three ways to supply one, strongest first.- Workload Identity (best)
- Kubernetes Secret
- Docker or a plain host
On GKE, GCE or Cloud Run, omit Bind the Kubernetes service account to a GCP one:Rotation becomes a GCP concern rather than a redeploy.
credentials_file. Vertex then resolves
Application Default Credentials, and there is no credential on disk at
all: the pod’s identity is the credential, so nothing can leak from an
image layer, a backup or a kubectl cp.config.yaml
There is no environment-variable option
The config reads no environment variable and performs no${VAR} interpolation, deliberately, so this does not work:
/proc/<pid>/environ, docker inspect and a core dump all expose a process’s
environment. A 0400 file exposes it to none of them.
What protects the key once it is loaded
The third layer is the one that catches you otherwise. A plain string escapes
through a struct dump, a debug endpoint, a log line and a panic trace; the
credential renders as
[REDACTED] through all four, so a field added beside
it later cannot leak by someone forgetting a tag.
GET /config returns exactly this, with no credential field to omit:
Reading the verdicts
Every classified statement carries its risk into the audit trail, on allowed statements as well as denied ones:ai_status is the analyzer’s own vocabulary and keeps the specific word: ok, cached, skipped, error, budget_exhausted, refused. A Rego policy sees a generic status instead, because a policy should not have to learn this package’s reasons: budget_exhausted and refused both arrive as unavailable, with the specific word in the finding’s reason.
That feeds a per-session rollup that keeps the highest risk the session reached:
A lane can carry several
ai_analysis rules. If more than one classifies the same statement, the record keeps the highest risk reported, together with the action that rule mapped it to, rather than whichever rule ran last.What startup refuses
Each of these would otherwise load, evaluate and do nothing:
Check before you deploy:
Limits worth knowing
- A verdict is a model’s opinion, sampled once. The same statement can classify differently on two runs, and the cache freezes whichever answer came first for its TTL. Keep the risks you can describe in an
operationortablerule, which costs nothing and survives a vendor outage. - Requests only. A response verdict cannot prevent a write that already ran.
- A slow classification can outlive the upstream’s idle budget. The Sidecar dials the upstream when it accepts, then holds the request while it classifies. An upstream with a short keep-alive (gunicorn defaults to 2s) hangs up before a 3s model call returns, and the client reads an empty reply. Raise the upstream’s idle timeout above your analyzer’s p99. The cache hides this after the first call for a given shape, so it shows up as a rare first-request failure.
- A model can refuse to classify. The reply carries
stop_reason: refusaland no verdict, and underfail_open: truethe statement is allowed. The Sidecar recordsmodel refused to classify this statement. Watch for these in the audit trail during an observe-only rollout: a model that refuses is the wrong model for this job. - No human review.
require_reviewis declared in the schema and refused at startup, so a config you write today stays valid when a review backend lands.deferis the closest thing that works now: a decide-phase policy holds the decision, though it still answers in milliseconds rather than waiting for a person.
Next
Policy Rules
Every rule type, deferring a match to Rego, and the findings a policy reads.
Config File
Every other section of the config, and the full list of what startup refuses.
Components and Architecture
How a request flows through the Sidecar and where each verdict is decided.