> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.hoop.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Analyzer Reference

> Classify statements with a language model and block on the risk it reports

An `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](#the-three-cost-controls) before you enable it anywhere real.

<Note>
  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.
</Note>

***

## A working config

```yaml config.yaml theme={"dark"}
pii:
  entities: [EMAIL_ADDRESS, US_SSN, BR_CPF]

analyzer:
  provider: vertex                 # vertex | anthropic | openai
  model: claude-sonnet-4-5@20250929
  extra: {project: my-gcp-project, region: global}
  # credentials_file omitted: Application Default Credentials.
  timeout_sec: 10
  fail_open: true                  # the default, deliberately
  send: redacted                   # raw | redacted | refuse
  max_input_bytes: 8192
  cache: {size: 4096, ttl_sec: 900}
  max_calls: 500

listeners:
  - name: appdb
    protocol: postgres
    listen: 0.0.0.0:15432
    upstream: appdb:5432
    policy:
      rules:
        - name: risky-writes
          type: ai_analysis
          trigger: {operations: [update, delete]}
          high: block
          medium: warn
          low: allow
          message: refused by risk analysis

  - name: api
    protocol: http
    listen: 0.0.0.0:18080
    upstream: internal-api:8080
    http:                          # required, see below
      capture_body: true
      max_body_bytes: 8192
      headers: [Content-Type]
    policy:
      rules:
        - name: risky-payloads
          type: ai_analysis
          trigger: {resources: ["/orders/**", "/admin/**"]}
          high: block
          medium: warn
```

***

## Where it runs in the chain

Evaluators compose in ascending order of cost, and the chain stops at the first denial:

```
Statement ──> local rules ──> OPA ──> analyzer ──> OPA (decide)
              free            ~2ms    ~100ms-2s    only where something defers
                                      costs money
```

A `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](#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.

<Steps>
  <Step title="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](#the-gate-phase) is the exception: there the policy decides what gets classified, and an empty trigger is how an operator says so.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

Watch the hit rate before you enable a blocking action:

```bash theme={"dark"}
curl -s localhost:19000/stats | python3 -m json.tool
```

<Tip>
  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 [Policy Rules](/docs/setup/configuration/hoop-inspect/policy-rules).
</Tip>

***

## Actions

| Action  | Effect                                                                               |
| ------- | ------------------------------------------------------------------------------------ |
| `allow` | Forward. The verdict is still recorded.                                              |
| `warn`  | Forward and record the risk. Observe-only, one tier at a time.                       |
| `block` | Deny, with the model's own title in the protocol's error frame.                      |
| `defer` | Forward, and hand the decision to a decide-phase OPA call that reads the risk level. |

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:

```
FATAL:  unbounded delete against the customer ledger
```

### 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:

```yaml theme={"dark"}
- name: risky-writes
  type: ai_analysis
  trigger: {operations: [update, delete]}
  high: defer          # the analyzer classifies; the policy rules
  medium: warn
```

The analyzer still classifies, annotates and audits. The level travels to the decide-phase call as a finding under the `ai_analysis` source:

```json theme={"dark"}
"findings": {
  "ai_analysis": {"rule": "risky-writes", "status": "ok", "reason": "",
                  "values": {"risk_level": "high"}}
}
```

```rego theme={"dark"}
package hoop.inspect

import rego.v1

# A single-call lane sends no phase, so default it rather than
# testing input.phase directly.
phase := object.get(input, "phase", "decide")

default allow := false

allow if {
	phase == "decide"
	not blocked
}

blocked if {
	f := input.findings.ai_analysis
	f.status in {"ok", "cached"}      # the two answered statuses
	f.values.risk_level == "high"
	input.operation == "delete"
}
```

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. [Policy Rules](/docs/setup/configuration/hoop-inspect/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.

<Warning>
  `require_review` is **refused at startup**. Holding a statement for human approval needs a review backend this build does not have, and a config that reads as an approval gate while forwarding every statement is worse than one that fails.
</Warning>

***

## 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:

```yaml theme={"dark"}
policy:
  opa:
    url: http://opa:8181/v1/data/hoop/inspect
    gate: true
  rules:
    - name: risky-writes
      type: ai_analysis
      # no trigger: on a gated lane the policy decides what is classified
      high: defer
```

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:

```rego theme={"dark"}
# Same package as the decide rules above.

allow if phase == "gate"

request["ai_analysis"] := true if {
	phase == "gate"
	input.operation in {"update", "delete"}
	some r in input.relations
	r.access == "write"
	r.name == "customers"
}
```

`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.

<Note>
  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.
</Note>

`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 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](/docs/setup/configuration/hoop-inspect/config-file#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 `/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.

<Note>
  Set `fail_open: false` where the classification is a compliance requirement, and accept that a provider outage then stops traffic.
</Note>

***

## What leaves the process

`send` decides, using the same detector that powers masking:

| `send`     | Behavior                                                                     |
| ---------- | ---------------------------------------------------------------------------- |
| `raw`      | The statement as written.                                                    |
| `redacted` | Detected entities are named; their values are withheld.                      |
| `refuse`   | A statement containing a detected entity is denied locally. No call is made. |

`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.

<Warning>
  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`.
</Warning>

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.

<Warning>
  `analyzer.prompt` is **process-wide**. It reaches every `ai_analysis` rule on 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.
</Warning>

Protocol- and lane-specific wording belongs on a rule, which wins:

```yaml theme={"dark"}
analyzer:
  prompt: |
    You are classifying traffic to a regulated production environment
    holding customer financial records. Anything reading or modifying
    customer data is at least medium risk.

listeners:
  - name: appdb
    policy:
      rules:
        - name: risky-writes
          type: ai_analysis
          trigger: {operations: [update]}
          high: block
          prompt: |
            You are classifying SQL against the customer ledger. An UPDATE
            with no WHERE clause is always high risk, and so is any schema
            change.
```

Precedence is **rule → analyzer → 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:

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

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 rules it applies to, so a reworded prompt takes effect on the next statement rather than after the cache TTL.

***

## 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.

```yaml theme={"dark"}
  - name: api
    protocol: http
    http:
      capture_body: true
      max_body_bytes: 8192
      headers: [Content-Type]
```

An `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.

<Warning>
  `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.
</Warning>

***

## Providers

<Tabs>
  <Tab title="Google Vertex">
    ```yaml theme={"dark"}
    analyzer:
      provider: vertex
      model: claude-sonnet-4-5@20250929
      extra:
        project: my-gcp-project
        region: global          # or a region like us-east5
      # credentials_file omitted -> Application Default Credentials
    ```

    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.

    <Tip>
      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.
    </Tip>

    Set `region: global` or a multi-region endpoint. A single-region endpoint is an availability risk for something sitting on a database hot path.
  </Tab>

  <Tab title="Anthropic">
    ```yaml theme={"dark"}
    analyzer:
      provider: anthropic
      model: claude-sonnet-4-5
      credentials_file: /run/secrets/anthropic-key
    ```
  </Tab>

  <Tab title="OpenAI">
    ```yaml theme={"dark"}
    analyzer:
      provider: openai
      model: gpt-4.1
      credentials_file: /run/secrets/openai-key
      # endpoint: https://my-azure.openai.azure.com/...  for Azure or a compatible API
    ```

    The `openai` provider also serves Azure OpenAI and any OpenAI-compatible endpoint. Set `endpoint` and leave everything else the same.
  </Tab>
</Tabs>

One provider serves every lane, so the credential is read once.

***

## The credential

The config holds a **path**, never the key itself. Three ways to supply one,
strongest first.

<Tabs>
  <Tab title="Workload Identity (best)">
    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`.

    ```yaml config.yaml theme={"dark"}
    analyzer:
      provider: vertex
      model: claude-sonnet-4-5@20250929
      extra: {project: my-gcp-project, region: global}
      # no credentials_file
    ```

    Bind the Kubernetes service account to a GCP one:

    ```bash theme={"dark"}
    gcloud iam service-accounts add-iam-policy-binding \
      hoop-inspect@$PROJECT.iam.gserviceaccount.com \
      --role roles/iam.workloadIdentityUser \
      --member "serviceAccount:$PROJECT.svc.id.goog[default/hoop-inspect]"

    kubectl annotate serviceaccount hoop-inspect \
      iam.gke.io/gcp-service-account=hoop-inspect@$PROJECT.iam.gserviceaccount.com
    ```

    Rotation becomes a GCP concern rather than a redeploy.
  </Tab>

  <Tab title="Kubernetes Secret">
    For a cluster outside GCP, or for an Anthropic or OpenAI key.

    ```yaml config.yaml theme={"dark"}
    analyzer:
      provider: vertex
      model: claude-sonnet-4-5@20250929
      extra: {project: my-gcp-project, region: global}
      credentials_file: /run/secrets/vertex/key.json
    ```

    ```yaml deployment.yaml theme={"dark"}
    containers:
      - name: hoop-inspect
        command: ["hoop", "start", "inspect"]
        env:
          - name: HOOP_SIDECAR_CONFIG
            value: /etc/hoop-inspect/config.yaml
        volumeMounts:
          - {name: config, mountPath: /etc/hoop-inspect,  readOnly: true}
          - {name: vertex, mountPath: /run/secrets/vertex, readOnly: true}
    volumes:
      - name: config
        configMap: {name: hoop-inspect-config}
      - name: vertex
        secret:
          secretName: hoop-inspect-vertex
          defaultMode: 0400
    ```

    <Warning>
      `defaultMode: 0400` is required. Kubernetes mounts secret files `0644` by
      default, and the Sidecar refuses anything readable by group or other:

      ```
      credential file is readable by group or other:
      /run/secrets/vertex/key.json is 0644, want 0600 or stricter
      ```

      That refusal is deliberate. A key every process on the node can read is
      already disclosed, and startup is the only moment anyone acts on it.
    </Warning>

    Mounting config and credential separately keeps the ConfigMap safe to
    read, diff and commit.
  </Tab>

  <Tab title="Docker or a plain host">
    ```yaml config.yaml theme={"dark"}
    analyzer:
      provider: anthropic
      model: claude-sonnet-4-5
      credentials_file: /run/secrets/anthropic-key
    ```

    ```bash theme={"dark"}
    printf '%s' "$ANTHROPIC_KEY" > /run/secrets/anthropic-key
    chmod 600 /run/secrets/anthropic-key
    ```

    Use `printf`, not `echo`. `echo` appends a newline, and while the Sidecar
    trims it, a key with trailing whitespace fails authentication somewhere
    else with a message naming neither the whitespace nor the file.

    In Compose:

    ```yaml docker-compose.yml theme={"dark"}
    services:
      hoop-inspect:
        volumes:
          - ./config.yaml:/etc/hoop-inspect/config.yaml:ro
        secrets:
          - source: llm-key
            target: /run/secrets/anthropic-key
            mode: 0400
    secrets:
      llm-key:
        file: ./anthropic-key      # add this to .gitignore
    ```
  </Tab>
</Tabs>

### There is no environment-variable option

The config reads no environment variable and performs no `${VAR}` interpolation, deliberately, so this does not work:

```yaml theme={"dark"}
analyzer:
  credentials_file: ${VERTEX_KEY}   # taken literally as a filename
  api_key: sk-abc123                # rejected: unknown field
```

`/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

| Layer                                    | What it stops                                                 |
| ---------------------------------------- | ------------------------------------------------------------- |
| A path in the config, never the material | The key reaching git, a ConfigMap, a diff or a support ticket |
| The `0600` refusal at startup            | A key any local account can read                              |
| A type that refuses to print             | `%v`, `%#v`, `json.Marshal` and structured logs               |
| A curated `/config`                      | The admin endpoint publishing it                              |

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:

```json theme={"dark"}
{
  "provider": "vertex",
  "model": "claude-sonnet-4-5@20250929",
  "endpoint_host": "",
  "send": "redacted",
  "fail_open": true,
  "custom_prompt": false
}
```

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:

```json theme={"dark"}
{"kind": "statement", "operation": "update", "allowed": true,
 "metadata": {"risk_level": "medium", "risk_action": "warn",
              "ai_status": "ok", "ai_rule": "risky-writes"}}
```

`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:

```bash theme={"dark"}
curl -s 'localhost:19000/api/sessions?limit=1' | python3 -m json.tool
curl -s localhost:19000/api/stats             | python3 -m json.tool
```

```json theme={"dark"}
{"id": "45edf2c8…", "connection": "appdb", "verdict": "clean",
 "risk_level": "medium"}
```

Only the classification is recorded. The model's title and explanation reach the user on a denial, but never the audit record.

<Note>
  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.
</Note>

<Tip>
  Roll out with every tier on `warn`, watch `by_risk` in `/api/stats` and the cache hit rate in `/stats` for a week, then move `high` to `block`.
</Tip>

***

## What startup refuses

Each of these would otherwise load, evaluate and do nothing:

| Config                                                                       | Result                                                                      |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| An `ai_analysis` rule with no `analyzer` section                             | Refused, naming the lane                                                    |
| An `ai_analysis` rule with no `trigger`                                      | Refused: it would classify nothing. A gated lane is the exception.          |
| An `ai_analysis` rule naming no action for any risk level                    | Refused: every verdict would allow                                          |
| `require_review` as an action                                                | Refused: this build cannot hold a statement                                 |
| A risk level set to `defer` with no `policy.opa.url`                         | Refused: a finding nobody reads forwards everything                         |
| `action` set on an `ai_analysis` rule                                        | Refused: this type defers per risk level through `high`, `medium` and `low` |
| `policy.opa.gate: true` on a lane with no `ai_analysis` rule                 | Refused: a round trip per statement that gates nothing                      |
| A `provider` the binary does not link                                        | Refused, listing what it does link                                          |
| A credential file readable by group or other                                 | Refused, reporting the mode                                                 |
| `send: redacted` or `refuse` with no `pii` section                           | Refused: nothing to detect with                                             |
| An endpoint with userinfo or a query string                                  | Refused: `/config` would publish it                                         |
| An `ai_analysis` rule on an HTTP lane without `http.capture_body`            | Refused: every request would be skipped                                     |
| A negative `max_calls`, `cache.size`, `cache.ttl_sec` or `max_output_tokens` | Refused: a negative reads as "off" with no warning                          |
| An `http` block on a non-HTTP lane                                           | Refused, naming the lane                                                    |
| `authorization`, `cookie` or `proxy-authorization` allowlisted               | Refused, naming the header                                                  |
| A Vertex credential that cannot mint a token                                 | Refused, distinguishing parse, IAM and clock failures                       |

Check before you deploy:

```bash theme={"dark"}
hoop start sidecar --config config.yaml --validate
```

```
config OK: 2 listener(s)
  appdb            postgres  enforcing 1 rule(s) + 1 ai rule(s)
  api              http      enforcing 1 rule(s) + 1 ai rule(s)
```

***

## 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 `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. The Sidecar records `model 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_review` is declared in the schema and refused at startup, so a config you write today stays valid when a review backend lands. `defer` is 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

<CardGroup cols={2}>
  <Card title="Policy Rules" icon="shield-halved" href="/docs/setup/configuration/hoop-inspect/policy-rules">
    Every rule type, deferring a match to Rego, and the findings a policy reads.
  </Card>

  <Card title="Config File" icon="file-code" href="/docs/setup/configuration/hoop-inspect/config-file">
    Every other section of the config, and the full list of what startup refuses.
  </Card>

  <Card title="Components and Architecture" icon="sitemap" href="/docs/setup/configuration/hoop-inspect/components">
    How a request flows through the Sidecar and where each verdict is decided.
  </Card>
</CardGroup>
