Skip to main content
The analyzer is a per-listener component: an 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_approval, which files a held statement with the Control Plane, or, on a Sidecar with no Control Plane, with the person running it in a terminal.

A working config

Two blocks share the work. The top-level analyzer 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
A field the block does not name inherits the top-level value, so the block overrides exactly what it writes down. send, fail_open, timeout_sec, max_input_bytes, max_calls, rate_limit and cache all work this way; rate_limit and cache merge field by field, so a block naming only calls keeps the inherited per_sec and burst. 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:
A 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 four 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 the trigger matches are sent. Everything else allows for free. The flat lists match a statement that names any listed operation, table or resource. An any item matches only when every field it names matches, and an exclude item removes a statement whatever matched it:
With only exclude, a plain lane classifies every statement except the excluded ones. On a gated lane a gate request replaces the whole trigger, exclude included.Omit the trigger and every statement on the lane is classified: declaring the analyzer is the opt-in, and the cache, max_calls and rate_limit 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

rate_limit caps the speed

A token bucket per listener: calls per per_sec seconds, with at most burst sent back to back after a quiet period (burst defaults to calls). calls and per_sec are required together, after inheritance. An empty bucket reports rate_limited, and the listener classifies again as the bucket refills: 30 per 60 sends 30 at once, then one every two seconds.A throttled statement falls through like one past max_calls, and a listener that holds statements for approval denies it until a token is back. A throttled call does not spend max_calls, and a cache hit spends neither. The bucket keys on the listener name, so a hot reload continues it rather than refilling it; a lower burst takes effect at once, a higher one only as the bucket refills.--validate prints what each listener can spend in any 24 hours, and how long max_calls lasts at the full rate. The process log gets one warning when a bucket runs empty and one line, with the refused count, when it grants again.
4

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 approval 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. Unlike rate_limit it never refills: once spent, it stays spent until a restart. With both spent, the status is budget_exhausted.
A Sidecar older than 1.198.0 cannot read rate_limit. The Control Plane refuses to serve a configuration that carries it to one, naming the release to upgrade to. The same holds for a trigger’s any and exclude, which need a Sidecar that reports analyzer_trigger_items, and for the sampling keys and max_retries.
Watch the hit rate before you enable a blocking action. status="cached" against status="ok" is the share of statements that cost nothing:
Trigger on operations for anything load-bearing. tables comes from a scanner rather than a full SQL grammar, and a statement whose relations it could not determine does not match a table trigger. operations reads the statement’s most consequential effect, so a data-modifying CTE triggers on the delete it performs. See Guardrail Rules.

Actions

A risk level you do not name defaults to allow, so you opt into blocking a tier by writing it down. require_review, the earlier name of require_approval, is still accepted. 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:
The analyzer still classifies, annotates and audits. The level travels to the decide-phase call as a finding under the ai_analysis source, with rule carrying the listener’s name, because a component has no rule name:
Guard on 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_approval 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 approver groups, the approval count and the force-approval list. The listener holds only the rule’s name, and the Control Plane authorizes each approval request against the configuration it stored for that Sidecar.
require_review is the earlier spelling of require_approval, and the Sidecar still accepts it: it folds one onto the other when it loads the config. The Control Plane stores and serves require_review, so a Sidecar older than 1.244.0 still reads a rule saved with the new spelling.
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.

Approving in the terminal

A Sidecar with no Control Plane can still hold statements, when it runs as hoop start sidecar in a terminal: stdin and stdout both a TTY. Each held statement waits for the person running it, in the TUI’s Approvals section. That section shows the statement, the analyzer’s risk level and explanation, and the listener, and the person approves or rejects it there. Such a listener needs no approval_rule, because there is no Control Plane to hold one.
The rules are the Control Plane’s:
  • An approval releases one statement.
  • A resend of the same bytes answers from the same approval request.
  • A rejection is final for that approval request.
  • An approval request nobody decided, and an approval nobody used, expire after the 30-minute wait.
Approve stays off until the whole statement has been on screen. Each decision is written to the Sidecar’s log with the approver’s name. The TUI also saves the captured audit trail and log under ~/.hoop/sidecar/.
A configured Control Plane always wins. Its approval_rule decides who approves, and the terminal never releases a statement the Control Plane governs. Off a terminal (a pipe, a container, CI), there is nobody to ask, and the Sidecar refuses require_approval at startup as before.
Every protocol can hold. What the approver reads differs per protocol; see What a hold files. The approval runs last. The Sidecar files an approval request only after every other check allowed the statement, the decide-phase OPA call included. A decide denial files nothing, pages nobody and spends no approval. The decide call sees the pending approval as input.review (the key keeps its earlier name, so existing Rego keeps working; see the OPA input document), and it can deny the statement but cannot skip the approval. A hold waits on the connection for up to 30 minutes. Every 5 seconds the Sidecar asks the Control Plane about that one approval request, and the poll never files a new one. 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 approval id:
The wait and the poll interval are constants, with no config field. A Control Plane older than the Sidecar has no poll route, so the Sidecar denies after the first poll. The wait often ends before 30 minutes. Each of these stops it before an approval is claimed, so the approval stays for the retry: 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 approval and lets it through, unless the approval expired.
2

An approval is spent once

A third run of the same statement files a new approval request. A rejection or a revocation ends that one approval request, so running the statement again files a new one and pages the approvers. With spend_approval_on: accepted, a retry that the upstream refuses does not spend the approval.
An approval can be revoked until the Sidecar uses it. Revoke it from the Approvals page, or with PUT /api/approvals/<id> (or the earlier PUT /api/reviews/<id>) and status REVOKED. A hold that is still waiting denies on its next poll, and the Slack message says the approval was revoked. Once the Sidecar uses the approval, the approval request is EXECUTED and a revoke answers 400: the statement already ran. Any decision that loses that race to the Sidecar answers 400 the same way. A reserved approval (PROCESSING) cannot be revoked either. If the upstream refuses the statement, the approval is APPROVED again and can be revoked. The Control Plane matches on the exact bytes, so the retry must be the same statement, not an equivalent one. On an http listener, the match does not bind a body value that match_ignore names. A client using prepared statements sends the query with its parameters unbound, so an approval releases that query shape rather than one set of values. A statement larger than 100 KB is refused by the Control Plane rather than filed for approval.

Time limits

An approval request stays open until a person decides it and the Sidecar uses the approval, unless its rule sets a limit. On the Control Plane AI Analyzer rule, two optional fields show under Approvers: Each field takes minutes, from 1 minute to 7 days. An empty field means no limit, and a rule with no limit works as before. Through the API the fields are pending_ttl_sec and approval_ttl_sec, in seconds, from 60 to 604800, and 0 clears one. An approval request keeps the limits that its rule had when it was filed. An expired approval request releases nothing. The Sidecar denies with the approval request expired; running the statement again files a new approval request, and an older Sidecar with the statement was not released. Running the statement again files a new approval request and pages the approvers. The Approvals page shows the deadline, and GET /api/sidecars/reviews/<id> and the MCP tool approval_status carry it as expires_at. All three show Expired when it passes. A time to decide shorter than 30 minutes ends a hold sooner: the next poll reads Expired and denies. A limit longer than 30 minutes does not make a hold wait longer.

Spend an approval only on an accepted answer

By default the Sidecar spends an approval when it releases the statement. An http or grpc request that the upstream refuses still uses it, for example a 401 because a bearer token expired during the hold. With spend_approval_on: accepted, the Control Plane reserves the approval (PROCESSING), and the Sidecar reports the upstream’s answer before the client reads it:
A refused request ran nothing, so a resend of the identical statement with a fresh credential takes the approval again. A 5xx spends it, because the upstream can do the work before it fails. A resend while the approval is PROCESSING is not released a second time. A hold waits for the answer. approval_mode: return denies with another request is using this approval; check the approval request before resending. If the Sidecar never reports the answer (it stopped, or the Control Plane was unreachable), the approval is spent 10 minutes after the release. A late answer can spend it, but cannot give it back. On one HTTP/1 connection, a reserved request pipelined ahead of another held request is forwarded only when that hold ends. If that takes more than 10 minutes, the approval is spent, as with forward. spend_approval_on: accepted is valid on http, grpc and spanner listeners where a risk level asks for require_approval, on a Sidecar with a Control Plane. The Control Plane serves it only to a Sidecar that supports it, and refuses the config for an older one. A Control Plane older than the setting ignores it and spends the approval at release, and the Sidecar logs a warning.

Return mode for agents

An agent’s tool call ends long before a person answers, so a 30-minute hold is a hang to it. approval_mode: return files the approval request and denies at once with the approval id. The agent resends the identical statement after approval.
review_mode is the earlier spelling of approval_mode, and still accepted. Setting both to the same value loads; setting them to different values is refused at startup, and the Control Plane refuses to save such a rule. The Control Plane serves review_mode. On the Control Plane, set While held for approval on the AI Analyzer rule: Wait for the decision (hold) or Deny at once, run again after approval (return). The field shows when a risk level is Hold for approval. When the Sidecar config has an mcp: block, the denial leads with the approval id and names the MCP tool approval_wait, which waits for the decision. See Approval Status for Agents for the MCP server, the status path on an http listener and the approval fields on a denial. approval_mode is valid only on a listener where a risk level asks for require_approval. The Control Plane serves return only to a Sidecar at 1.196.0 or later, and refuses the config for an older one.
approval_mode: return applies to every client on the listener, people included. A developer in psql gets the denial too, and has to run the statement again after approval. Give agents their own listener with return, or keep hold on a shared listener and let each agent opt in.

A client picks its own mode

A client asks for hold or return, and that overrides the listener for its statements:
The earlier spellings, x-hoop-review-mode, hoop-review= and hoop_review_mode, are still accepted. A client that sends both spellings must send the same value in each: two different values are ignored, and the listener mode applies. A postgres application_name that carries both tokens is ignored the same way, since only one of them can end the name. The postgres token is the whole application_name, or follows a space or a ;, so the client keeps its own name in front. A value other than hold or return is ignored and the listener mode applies, so a typo does not make an agent wait for a person. The choice is safe to leave to the client: both modes need the approval, and only the wait changes. The audit record carries review_mode and review_mode_source (listener or client), names kept for compatibility. The header reaches policy and audit, and never the analyzer prompt or the verdict cache, so both modes share one classification.

The retry contract

An agent in return mode follows four rules:
1

Resend the identical bytes

The Control Plane matches an approval on the exact statement. A reformatted statement (other whitespace, other quoting, a new comment, a new trace id) files a new approval request and pages the approvers again. A body value that match_ignore names can differ.
2

Resend once, after approval

Wait with the MCP tool approval_wait, poll approval_status, or on an http listener read the status path. An approval releases one run. When approval_status shows expires_at, resend before it: an expired approval releases nothing.
3

Do not resend a rejected, revoked or expired approval request

The decision is final for that approval request. A resend files a new approval request and pages the approvers again, so only a person asks again.
4

Run a holdable statement in autocommit

On postgres, mysql, mssql and mongodb a denial closes the connection, and the database rolls back any open transaction with it. The earlier statements of that transaction are lost, and the retry runs on a new connection. This holds for every denial: return mode, a hold that times out, a rejection.

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 lane captures the request body without capture_body, and the body also reaches policy, the analyzer and the audit trail. Five consequences:
  • A body larger than http.max_body_bytes is 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 approval request and pages the approvers again, unless match_ignore names that value in the body.
  • 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 approver decides.
A statement that is not text, such as the protobuf body kubectl sends for create, reaches the approver as a notice line, then the bytes. A byte that is not printable shows as \xNN, and a backslash as \\:
Text an approver cannot see, such as a zero-width space or a bidi control, takes the same form, so the text shown is the bytes that run, apart from a body value that match_ignore names. The match stays on the filed bytes, not on the escaped form. grpc files the method path, a newline, then the message as protojson. spanner files the SQL alone when it reads SQL from the message, and the grpc form otherwise. The spanner approver reads the SQL, not its parameters or the database it runs against, so an approval releases the query with any bound values. Both need grpc.capture_payload: true, and the http consequences apply:
  • A message larger than grpc.max_payload_bytes denies 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.
ssh holds 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:
The approval is exact, apart from a body value that match_ignore names; the classification is not. The cache keys on the statement shape with literals stripped, so WHERE tenant = 'test' and WHERE tenant = 'prod' share one verdict. A shape rated high holds every statement of that shape, each filing its own approval request. 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_approval. Each of these denies there: A statement the trigger does not match still forwards, as on any listener. Human approval works only through the analyzer. A guardrail rule and a deprecated ai_analysis rule both refuse require_approval (or require_review) at startup, along with the other misconfigurations listed in What startup refuses.

Ignoring a volatile body value

kubectl rollout restart writes the current second into its patch, so a resend never matches the approval. Name that value in match_ignore, and the Sidecar files a fixed marker in its place:
The approver reads:
The approval does not bind an ignored value. The request that runs carries a value the approver did not read. Name only a value that cannot change the approver’s decision.
  • resources is required. It takes the same globs as an http_resource rule, and matches the normalized resource.
  • A field is a list of object keys from the body root, so a key with dots stays one key.
  • The Sidecar replaces only a string, number, boolean or null. It files these unchanged: an object or array at the path, a path through an array, and a body that is not JSON.
  • The Sidecar replaces the value in the client’s bytes. Every other byte, a duplicate key included, reaches the approver as sent.
  • The audit record carries review_match_ignored, the count of ignored values (the name is kept for compatibility).
  • match_ignore is valid only on an http listener where a risk level asks for require_approval.
On the Control Plane, the AI Analyzer rule has no match_ignore field. Put it in the Sidecar’s config file before the first import, or send it in sidecar_spec with PUT /api/ai/session-analyzer/rules/<name>. That call replaces the whole Sidecar block of the rule. A save in the UI keeps match_ignore while a risk level is Hold for approval. The Control Plane serves match_ignore only to a Sidecar that reports the analyzer_match_ignore capability on its handshake, which 1.238.0 and later do. It refuses the config for an older one.

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:
Both calls hit the same URL and carry 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 classifies FromClient 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 to fail_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 /metrics counts it under outcome="error" or outcome="timeout". 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.
A listener that holds statements for approval is the exception. There fail_open is not consulted, and a failed classification, a spent max_calls, an empty rate_limit bucket 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.
A Sidecar whose job is keeping taxpayer IDs out of a database’s own query log must not post them to a model vendor. If you run PII detection, run send: redacted.
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.
analyzer.prompt at the top level is process-wide. It reaches every lane, database and HTTP alike. Keep it protocol-neutral; guidance reading “you are classifying SQL against a customer database” follows an HTTP statement to the model and has it reasoning about DROP while it looks at a JSON body.
Protocol- and lane-specific wording belongs on the listener’s own block, which wins:
Precedence is listener block → top-level 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:
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 approval it denies everything.
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.
Neither failure raises an error. The classifier keeps answering, worse and leakier, so neither belongs in a config file. Changing a prompt invalidates cached verdicts for the lanes it applies to, so a reworded prompt takes effect on the next statement rather than after the cache TTL.

What the HTTP lane shows the model

The HTTP codec exposes nothing by default: no bodies, no headers. The http block decides what the analyzer sees, except that a lane that holds for approval captures request bodies.
A request with no body is classified from its request line and the headers in 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, unless the lane holds for approval. 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. For POST /orders/12345?export=all:
The request line keeps the literal id, the query string, the client’s escaping and the parameter order. The model judges intent, and ?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:
Without that refusal the rule would load, the trigger would match, the builder would decline, and every statement it named would be allowed carrying a 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.
authorization, cookie and proxy-authorization cannot be allowlisted and are refused at startup. A lane exposing them to policy has put a bearer token into every decision log and audit record.

Providers

Vertex authenticates with a GCP OAuth bearer minted from a service account and refreshed before expiry. --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:
For an open model, set 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.
Vertex routes the shared openapi endpoint by that prefix, so the relay refuses a model name without <publisher>/ at startup.
The analyzer reads its verdict from a forced tool call, so pick a model with function calling. A model without it fails each statement with model called no risk tool, and under fail_open: true the relay lets those statements through unscored.
Prefer Workload Identity and omit credentials_file. On GKE there is then no credential on disk at all: the pod’s identity is the credential, so you have nothing to leak or rotate.
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.
One provider serves every lane, so the credential is read once.
Behind a proxy that re-signs egress TLS (a transparent MITM in front of Google’s APIs, for Vertex or Gemini), add its CA with trust.ca_file. Every provider call uses that pool, Vertex token minting included. Do not use SSL_CERT_FILE: it replaces the system roots instead of adding to them.

Tuning the model call

Every key here is optional. Unset, the Sidecar sends nothing for it and the model’s own default applies, so a config that names none of them sends the request it always did. They are process-wide, like the model, and a change needs a restart.
config.yaml

Thinking

Gemini 3 and later models reason before they answer. The default tier, MEDIUM on the 3.x Flash models, can take one call past timeout_sec. A classification is one forced tool call and needs little reasoning, so thinking_level: low is the usual setting. Both keys apply to provider: gemini and to provider: vertex with publisher: google. Set one, never both: Google rejects a request with both, so startup refuses it. Any other publisher refuses them too. thinking_budget: 0 and thinking_budget: "0" load the same; quotes are optional for any extra value.
Each model accepts only some levels. gemini-3.8-flash takes low, medium and high, and has no minimal. Gemini 2.5 Pro cannot turn thinking off. The Sidecar cannot check this at startup, so an unsupported value fails every call with a 400, which /metrics shows as outcome="error". Check the model’s card before you set it.

Sampling

temperature (0 to 2), top_p (0 to 1), top_k (1 or more) and seed go to the provider under its own field names. A provider whose API has no field for one refuses it at startup, rather than send a setting that does nothing: Two limits come from the APIs, not from the Sidecar:
  • Gemini 3 and later models accept temperature, top_p and top_k and ignore them. Google points to thinking_level instead. The Sidecar does not check for this, because the model name is the only signal and it changes with every release.
  • Some Claude models refuse temperature and top_p together. That error comes from the API on each call.
Sampling narrows a model’s answer. It does not make two calls agree. The cache does: statements of one shape share one verdict for ttl_sec, with no second call. The shape of the answer is already fixed: every provider must call one of three risk tools, so the verdict is always low, medium or high.

Output length

max_output_tokens caps the length of the reply, 1024 by default. It does not set its shape; the forced tool call does. A tool call with a title and a short explanation needs a few hundred tokens.

Retries

max_retries re-sends a classification after a 408, 429, 500, 502, 503 or 504. The first retry waits about 250 ms, each next one twice as long, at most 4 s. A Retry-After from the provider replaces that wait, and Retry-After: 0 retries at once.
  • Every attempt shares timeout_sec. A retry never holds a connection longer than one call could. A wait that would outlast the deadline is not taken, and the provider’s own answer is reported.
  • Other failures are final. Any other status, a transport error or a timeout is not retried.
  • It costs no budget. A retry is not charged to max_calls or rate_limit, which count classifications.
Zero, the default, sends once.

Labels

extra.labels tags every call for GCP billing, as key=value pairs separated by commas. Keys start with a lowercase letter. Keys and values hold lowercase letters, digits, _ and -, up to 63 characters. Only provider: vertex with publisher: google sends them. provider: gemini and the other Vertex publishers refuse them at startup.
The Control Plane serves temperature, top_p, top_k and seed only to a Sidecar that reports analyzer_sampling, and max_retries only to one that reports analyzer_retries. The extra keys need no check: a Sidecar ignores an extra key it does not read.

The credential

The config holds a path, never the key itself. Three ways to supply one, strongest first.
On GKE, GCE or Cloud Run, omit 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
Bind the Kubernetes service account to a GCP one:
Rotation becomes a GCP concern rather than a redeploy.

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:
An endpoint URL carrying userinfo or a query string is refused at startup, because that view sits beside a read interface to the audit trail and a credential in a URL would be published there.

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, rate_limited, refused. A Rego policy sees a generic status instead, because a policy should not have to learn this package’s reasons: budget_exhausted, rate_limited and refused all arrive as unavailable, with the specific word in the finding’s reason. Where several analyzers see one statement, ai_status keeps the most degraded word, as the finding does, so a second analyzer that answered cannot hide a first one’s outage or throttling. 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:
Only the classification is recorded. The model’s title and explanation reach the user on a denial, but never the audit record.
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.
Roll out with every tier on warn, watch by_risk in /api/stats and the cache hit rate in /metrics for a week, then move high to block.

Metrics

The admin listener serves GET /metrics in the Prometheus text format. Prometheus, an OpenTelemetry Collector with the prometheus receiver, or any agent that reads that format can scrape it. A Sidecar with no analyzer answers an empty document, so one scrape job can cover every Sidecar.

The series

Histogram buckets are 0.25, 0.5, 1, 2, 3, 5, 7.5, 10, 15, 20, 30, 45 and 60 seconds. A timeout lands at about timeout_sec, because the duration covers the whole classification. The counters live for the life of the process and survive a hot reload.

Queries worth having

Two alerts cover most incidents: the timeout share above a few percent, and any sustained status="error" rate. Under fail_open: true both mean statements go through with no verdict.

Scraping it

prometheus.yml
/metrics shares the admin listener with the audit query API, which has no authentication. Scrape it on a private network or from inside the pod. Do not expose the admin port to reach the metrics. See the admin listener.

The same numbers in the log

A failed classification’s error says how long it ran and the limit, so a fast failure and an expired deadline read differently:
A retried failure adds the attempt count: (after 1.2s, timeout 10s, 3 attempts). With log_level: debug, each call also writes one line:

Reading a timeout

1

Confirm it is the deadline

The error ends in (after N, timeout T) with N close to T, and outcome="timeout" grows. An N far below T is a fast failure: read the status in the error.
2

Read the p99

If the p99 of outcome="ok" sits near timeout_sec, the model is slow, not down. Raise timeout_sec on that listener only, above the p99.
3

Cut the work per call

On Gemini 3 and later, set thinking_level: low. Lower max_input_bytes on listeners with large bodies. Narrow the trigger and turn on the cache so fewer statements reach the model.
4

Absorb transient failures

If outcome="error" comes with 429 or 503 in the log, set max_retries: 2. Retries share timeout_sec, so they never hold a connection longer.

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:
A lane still carrying deprecated rule-form analyzers reports + 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:
It folds every deprecated spelling onto its replacement, moves each 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:
Three things change with the move, all visible rather than behavioral traps:
  • metadata.ai_rule and the finding’s rule carry the listener name instead of the rule name, because a component has no rule name. Every field name in findings, audit metadata and /stats stays 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 guardrails block 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-level analyzer section.

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. temperature: 0 and a seed narrow the spread on models that honor them; Gemini 3 and later ignore temperature. Keep the risks you can describe in an operation or table rule, 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: refusal and no verdict, and under fail_open: true the statement is allowed. A listener that holds statements for approval denies it instead. The Sidecar records model 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.