analyzer: block declared beside guardrails, opa and mask, not inside them. It sends a statement to a language model and denies on the risk that comes back. It runs wherever a content builder renders the statement for a model: SQL on a postgres, mysql, mssql or clickhouse listener, request bodies on an http one.
It is the only evaluator 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.
Classification runs entirely in the Sidecar: it holds the provider credential and reads one YAML file, same as every other feature here. The one exception is
require_review, which files a held statement with the Control Plane.A working config
Two blocks share the work. The top-levelanalyzer section holds what is genuinely process-wide, the provider, the model and the credential, plus the defaults every lane inherits. Each listener’s own analyzer block holds what is per lane: the trigger, the risk-to-action map, the prompt, and any inherited default it wants to override.
config.yaml
send, fail_open, timeout_sec, max_input_bytes, max_calls and cache all work this way. Provider, model, endpoint and credential are never per lane: a second provider per lane would double the credential surface, so those stay in the top-level section. That split is also the reload boundary: editing a listener’s block hot-reloads like a rule edit, while the top-level section requires a restart, because the credential lives there.
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 guardrail 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 guardrail 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.Omit the trigger and every statement on the lane is classified: declaring the analyzer is the opt-in, and the cache and
max_calls bound the bill. --validate prints a per-lane note naming that cost, so nobody ships it by accident. A lane running the gate behaves differently: there the policy decides what gets classified, and an omitted trigger leaves it fully in charge — Rego’s silence means skip, and nothing is spent without an explicit request.2
The cache keys on the statement shape
WHERE id = 1 and WHERE id = 2 are one verdict: the Sidecar strips literals from SQL before hashing. On an HTTP lane the key holds the method, the normalized resource, the query string and the body, so /users/12345/orders and /users/67890/orders share an entry while ?export=all and ?export=none do not. A query value changes what a request does; a path id does not.Caching on shape beats caching on bytes. The shape carries the risk.3
max_calls is a backstop
A process-lifetime budget for the listener. Past it, statements fall through to the guardrails and OPA, the same outcome as a listener with no analyzer. A listener that holds statements for review denies instead: a statement with no risk level cannot be forwarded past a human gate. The budget keys on the listener name, so a hot reload that edits the block continues the running count rather than re-arming it.
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, with rule carrying the listener’s name, because a component has no rule name:
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. Guardrail Rules has the full status vocabulary.
defer on a lane with no opa.url loads, and denies on a match. Deferring to a decision that does not exist has to fail closed somewhere; --validate prints a note about it, and moving that failure from startup to runtime lets one file serve a deployment with OPA and a deployment without one.
Holding a statement for a person
require_review refuses a statement until a person approves it. approval_rule names the access request rule in the Control Plane that decides who may approve: the reviewer groups, the approval count and the force-approval list. The listener holds only the rule’s name, and the Control Plane authorizes each review against the configuration it stored for that Sidecar.
approval_rule is per listener, so the people who may release a statement against the payments database are not, by accident, the people who may release one against a reporting replica. Editing the block hot-reloads with the listener’s rules.
Every protocol can hold. What the reviewer reads differs per protocol; see What a hold files.
A hold waits on the connection for up to 30 minutes. Every 5 seconds the Sidecar asks the Control Plane about that one review, and the poll never files a new review. An approval that arrives in time runs the statement on the same connection, late. A rejection, a revocation, or an approval that another connection already used ends the wait and denies. Every denial carries the review id:
A timed-out statement is released by a retry. The developer asks an approver, then runs the same statement again:
1
The retry collects the approval
The Sidecar files nothing new. The Control Plane recognizes the statement, consumes the approved review and lets it through.
2
An approval is spent once
A third run of the same statement files a new review. A rejection stays, so a rejected statement is denied every time without paging anyone again.
What a hold files
postgres, mysql, mssql, mongodb and clickhouse file the statement as the codec reads it. http files the method, the target and the body:POST /transfers?dry_run=false, a blank line, then the body. The analyzer already needs capture_body: true. Five consequences:
- A body larger than
http.max_body_bytesis truncated by the codec, so the hold denies it without filing. The approval would bind to bytes nobody read. - A query value the codec redacts, such as a token or a password, is filed redacted. Requests that differ only in that value match one approval.
- A request carrying a trace id, a nonce or a timestamp never matches twice. Every attempt files its own review and pages the approvers again.
- A client that retries on its own timeout leaves two attempts in flight. The approval releases whichever claims it first.
- A request that arrives in more than one read reaches the upstream in part before the analyzer decides: the request line, the headers and the start of the body. A denial does not recall them, so a route that acts on headers alone runs whatever the reviewer decides.
grpc.capture_payload: true, and the http consequences apply:
- A message larger than
grpc.max_payload_bytesdenies without filing. - A message carrying a request id, a nonce or a transaction id never matches twice. Metadata is not filed, so a trace header does not break a match.
- The request headers reach the upstream before the analyzer decides; the message does not.
exec only, because the analyzer classifies exec_line alone. A shell sends no statements, so a user could type in a shell what exec would hold. A holding ssh listener must therefore drop shell from capabilities_allowed. An absent key admits shell, so write the list out:
WHERE tenant = 'test' and WHERE tenant = 'prod' share one verdict. A shape rated high holds every statement of that shape, each filing its own review. A shape rated low forwards without a hold, even when a later literal makes it the dangerous one. Set cache: {size: 0} on a listener where every statement must be judged on its own, and pay one model call per statement.
A holding listener fails closed. fail_open answers for a model vendor’s outage, not for a human gate, so it is not consulted on a listener where any risk level asks for require_review. Each of these denies there:
A statement the trigger does not match still forwards, as on any listener.
Human review works only through the analyzer. A guardrail rule and a deprecated
ai_analysis rule both refuse require_review at startup, along with the other misconfigurations listed in What startup refuses.
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.
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 analyzer block 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 guardrails and OPA both ran and both allowed, so a lane whose analyzer is down keeps every control except the paid one.
Set
fail_open: false where the classification is a compliance requirement, and accept that a provider outage then stops traffic.fail_open is not consulted, and a failed classification, a spent max_calls or an unreachable Control Plane denies. See Holding a statement for a person.
What leaves the process
send decides, using the same detector that powers masking:
Neither mode needs a
pii section: with the section omitted every supported entity is enabled, so a detector is always there to redact with. Set pii.entities to narrow what those modes look for.
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 the listener’s own block, which wins:analyzer.prompt → built-in. Setting neither is fine: the built-in guidance carries separate high-risk examples for SQL and for HTTP.
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, and on a listener that holds statements for review it denies 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.What the HTTP lane shows the model
The HTTP codec exposes nothing by default: no bodies, no headers. Thehttp block decides what the analyzer sees.
http.headers. On a REST API the path is the operation: GET /api/v1/namespaces/prod/secrets/db-root says what a kubectl user is about to read, and the Accept header says whether they want the names or the contents. capture_body: true adds a POST’s payload; without it a write reaches the model as its request line alone. A bodiless response (a 204, a 101) is never classified.
Headers you allowlist reach the model under the same terms as policy and the audit trail. authorization, cookie, proxy-authorization and set-cookie cannot be listed. The cache keys on the headers the model sees, so a per-request header such as kubectl’s Kubectl-Session UUID turns every request into a model call; leave it out.
A grpc or spanner lane needs grpc.capture_payload: true, because a payload is the only thing an RPC has to show. A lane whose protocol has no content builder at all is refused rather than left classifying nothing.
What the model receives
The Sidecar sends the request line as the client wrote it, then the resource the trigger matched on its own line where it differs, then the content type, the allowlisted headers in name order, and the body. ForPOST /orders/12345?export=all:
?export=all or ?limit=100000 is part of it. The resource form exists to fold those away for policy, so policy rules keep matching on Resource. Under send: redacted the detector runs over this whole text, path included, before it leaves the Sidecar. No header other than Content-Type reaches the model, whatever http.headers allowlists for policy.
An SSH lane classifies exec_line only
A command line is a whole instruction a model can reason about. A variable name (env_set) and a file path (every sftp_*) are short structural strings with no room for intent — a model asked to rate /srv/data.csv returns a guess at full price, once per path.
So the SSH content builder answers for exec_line and nothing else, and a trigger naming any other operation is refused at startup:
skipped finding indistinguishable from an unmatched trigger. Match the rest with a pattern_match rule scoped by operations, which costs nothing and reads paths and variable names exactly as well.
An interactive shell never reaches the analyzer at all: it produces no statements, because no keystrokes are reconstructed.
Providers
- Google Vertex
- Gemini (API key)
- 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.You choose the model family with publisher, and the relay calls that family’s Vertex API:One bearer token covers all three. The relay keeps no model list, so any model name Vertex accepts works, and it refuses an unknown
publisher at startup.For Gemini, set publisher: google and a Gemini model:publisher: openapi and the model ID from its Model Garden card, publisher prefix included. Enable the model on that card first. Some open models have no global endpoint, so pick a region from the card.openapi endpoint by that prefix, so the relay refuses a model name without <publisher>/ at startup.Set region: global or a multi-region endpoint where the model offers one. 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. This works for all three Vertex
publishers: Claude, Gemini and the open models.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. ai_rule names what produced the level: the listener name for an analyzer block, the rule name for a deprecated ai_analysis rule.
That feeds a per-session rollup that keeps the highest risk the session reached:
One block per lane. A lane wanting several analyzers, say different triggers with different prompts, has no block equivalent yet: it keeps its deprecated
ai_analysis rules until it can express itself as one block, and --validate counts what is left to migrate on each lane. If more than one evaluator classifies the same statement, the record keeps the highest risk reported, together with the action mapped to it, rather than whichever evaluator ran last.What startup refuses
Each of these would otherwise load, evaluate and do nothing:
One former refusal moved to runtime: a risk level set to
defer on a lane with no opa.url now loads, --validate prints a note, and a deferred verdict denies instead of reporting a finding nobody reads.
Check before you deploy:
+ ai analyzer (N deprecated ai rule(s)) instead, so the leftover migration work is visible per lane.
Migrating from type: ai_analysis rules
The analyzer used to be spelled as a guardrail rule: type: ai_analysis under guardrails.rules. That form is deprecated and still works: it loads, builds the same evaluator through the same code path, and prints a deprecation naming the listener’s analyzer block. --validate --strict turns the warning into a non-zero exit. Both spellings can serve one lane while you migrate, each as its own evaluator.
The Sidecar rewrites the file for you:
ai_analysis rule onto its listener’s analyzer block where the move is faithful, and emits a document that passes --validate --strict. Where the move would be lossy — a lane carrying two ai rules, a top-level rule some lane cannot absorb — the rule stays put and the report on stderr says so. Comments and key order from the original are not preserved; review the diff before deploying. hoop start sidecar --migrate is the same command.
Or by hand, move the rule’s fields onto the listener’s analyzer block; they keep their names:
metadata.ai_ruleand the finding’srulecarry the listener name instead of the rule name, because a component has no rule name. Every field name in findings, audit metadata and/statsstays the same, so dashboards and policies keyed on the fields keep working; anything keyed on the old rule name as a value sees the switch.- The call budget keys on the listener name. Two lanes that shared one rule name used to pay from one purse; two analyzer blocks never do.
- A rule in the top-level
guardrailsblock used to reach every lane; the analyzer block is per listener, so write one on each lane that wants it. What was genuinely process-wide about the analyzer already lives in the top-levelanalyzersection.
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. A listener that holds statements for review denies it instead. The Sidecar recordsmodel refused to classify this statement. Watch for these in the audit trail while the listener is still on staging: a model that refuses is the wrong model for this job.
Next
Guardrail 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.