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

# Config File Reference

> Every section of the Sidecar config file, and what startup refuses

One file is the whole configuration. The Sidecar reads it at startup, resolves every listener, and then tells you what it resolved.

YAML and JSON both work, and the file extension picks the parser: `.yaml` and `.yml` go through the YAML front end, anything else is read as JSON. Decoding is **strict**, so a mistyped key fails startup instead of disabling a control without telling you.

<Note>
  New to hoop-inspect? Start with [Get Started](/docs/setup/configuration/hoop-sidecar/get-started), which builds a working config one step at a time.
</Note>

***

## Shape of the file

Two concepts that used to share the name `policy` now have sections of their own. `guardrails` is Hoop's own rule engine, running in-process. `opa` is the optional integration with an OPA Data API endpoint you run yourself. Both are **defaults** at the top level, as `mask` is, and each listener inherits them unless it overrides them.

```yaml config.yaml theme={"dark"}
log_level: info

# A path to the license Hoop issued, or the document itself. --license and
# HOOP_LICENSE both outrank this. Omit it to run the free tier.
license: /etc/hoop-inspect/license.json

# The Control Plane this Sidecar reports to. Omit it to run standalone.
# HOOP_CONTROL_PLANE_URL outranks this. The token that authenticates to it
# is never written here — see --token and HOOP_SIDECAR_TOKEN below.
control_plane_url: https://hoop.your-company.com

admin:
  listen: 127.0.0.1:19000   # /healthz /stats /config /events /api/*

audit:
  file: "-"                 # stdout as JSON lines; a path appends to that file
  async_queue_size: 1024    # a slow sink must not block a user's query
  memory_buffer: 256        # last N events, readable at GET /events
  query_sessions: 500       # backs GET /api/sessions
  fail_closed: false        # true refuses a statement whose audit write failed

pii:                        # optional: omit it and every supported entity is enabled
  entities: [EMAIL_ADDRESS, US_SSN, CREDIT_CARD, BR_CPF, IBAN_CODE]

guardrails:                 # Hoop's own rules, inherited by every listener
  rules:
    - name: no-cpf-in-query
      type: pii
      entities: [BR_CPF]
      message: do not put a taxpayer id in a query; it lands in the database's own logs

mask:                       # inherited by every listener
  rules:
    - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}
    - {name: ssn, entities: [US_SSN], strategy: partial, keep_last: 4}

listeners:
  - name: appdb
    protocol: postgres
    listen: 0.0.0.0:15432
    upstream: appdb:5432
    guardrails:
      rules:
        - name: no-destructive-sql
          type: operation
          operations: [drop, delete, truncate]
          message: destructive statements are not permitted on appdb
    mask:
      rules:                # REPLACES the default list rather than extending it
        - {name: ssn-column, columns: [ssn], strategy: partial, keep_last: 4}
        - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}

  - name: mssqldb
    protocol: mssql
    listen: 0.0.0.0:11433
    upstream: mssql:1433
    guardrails:
      rules:
        - name: no-destructive-tsql
          type: operation
          operations: [drop, delete, truncate]
          message: destructive statements are not permitted on mssqldb

  - name: httpbin
    protocol: http
    listen: 0.0.0.0:18080
    upstream: httpbin:8080
    opa:                    # optional, and separate from the guardrails below
      url: http://opa:8181/v1/data/hoop/inspect
      fail_open: false
    guardrails:
      rules:
        - name: no-admin-api
          type: http_resource
          resources: ["/admin/**"]
          message: the admin API is not reachable through this proxy
        - name: no-upstream-5xx
          type: http_status   # response-side, so ext_authz cannot ask it
          statuses: ["5xx"]
          message: upstream failure suppressed by a guardrail
```

Every lane enforces, and a `mask` block carrying rules masks. Neither is a second switch you can forget to set.

***

## Top-level sections

| Key                 | Purpose                                                                                                                                                                           |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `log_level`         | `debug`, `info`, `warn` or `error`. Default `info`.                                                                                                                               |
| `license`           | The license this process runs under. A path or the document itself. `--license` and `HOOP_LICENSE` outrank it. See [Licensing](#licensing).                                       |
| `control_plane_url` | The Control Plane this Sidecar reports to. `HOOP_CONTROL_PLANE_URL` outranks it. Omit it to run standalone from the local config file alone. See [Control Plane](#control-plane). |
| `admin`             | Health, stats and the audit query API. Disabled when `listen` is empty.                                                                                                           |
| `audit`             | Where events go and what a failed write means.                                                                                                                                    |
| `pii`               | The detector engine. Omit it and every supported entity is enabled.                                                                                                               |
| `guardrails`        | Default Hoop rule set for every lane.                                                                                                                                             |
| `opa`               | Default OPA endpoint for every lane. Omit it and no lane calls OPA.                                                                                                               |
| `mask`              | Default response rewriting for every lane.                                                                                                                                        |
| `analyzer`          | The AI risk classifier. Omit it and `ai_analysis` rules are a config error. See [Risk Analysis](/docs/setup/configuration/hoop-sidecar/risk-analysis).                                 |
| `listeners`         | One entry per upstream.                                                                                                                                                           |

Keys starting `x-` are dropped before validation, so a YAML anchor block does not need a matching config field. See [sharing rules between lanes](#sharing-a-rule-block-between-lanes).

<Note>
  `policy` is the previous spelling for both `guardrails` and `opa`, and it is **deprecated**. A file still using it loads and warns at startup. See [Migrating from `policy`](#migrating-from-policy) for the field-by-field mapping.
</Note>

***

## Listeners

Each listener is one Envoy cluster's worth of traffic with its own enforcement stack.

| Field              | Meaning                                                                                                                                                                                                                |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`             | Identifies the lane in logs, in `/config`, in every audit event and in `input.context.connection`. Required. The physical `upstream` may change under it.                                                              |
| `protocol`         | `postgres`, `mssql` or `http`. Selects the codec.                                                                                                                                                                      |
| `listen`           | Bind address, or a filesystem path when `network: unix`.                                                                                                                                                               |
| `network`          | `tcp` (default) or `unix`. See [Transport](#transport).                                                                                                                                                                |
| `upstream`         | The real backend, `host:port`.                                                                                                                                                                                         |
| `upstream_tls`     | Encrypts the hop to the backend. See [Upstream TLS](#upstream-tls).                                                                                                                                                    |
| `downstream_tls`   | Terminates the client's TLS at the Sidecar. Only valid on a `postgres` lane, because pgwire negotiates in-band. See [Kerberos and SQL Server](/docs/setup/configuration/hoop-sidecar/kerberos#terminating-the-clients-tls). |
| `identity_header`  | Names an HTTP header carrying the authenticated subject.                                                                                                                                                               |
| `idle_timeout_sec` | Closes an idle connection. Zero disables it.                                                                                                                                                                           |
| `max_conns`        | Bounds concurrency. Zero is unlimited.                                                                                                                                                                                 |
| `guardrails`       | Adds to the top-level rule set for this lane.                                                                                                                                                                          |
| `opa`              | Replaces the top-level OPA endpoint for this lane.                                                                                                                                                                     |
| `mask`             | Replaces the top-level mask rules for this lane.                                                                                                                                                                       |
| `http`             | What the HTTP codec captures. Only valid on an `http` lane. See [Risk Analysis](/docs/setup/configuration/hoop-sidecar/risk-analysis#the-http-lane-needs-capture-body).                                                     |

<Warning>
  `identity_header` trusts a header, which is safe only when nothing but your proxy can reach the listener. Bind it to loopback or a unix socket. On a listener reachable from anywhere else, a caller can assert any identity.
</Warning>

<Note>
  Leave `idle_timeout_sec` unset for interactive sessions. `psql` idles between keystrokes, and a short value disconnects a developer mid-thought.
</Note>

### Transport

Each lane binds a TCP port or a unix socket. One field decides it, per listener, and **TCP is the default**: omit `network` and you get a port.

```yaml theme={"dark"}
listeners:
  - name: appdb-tcp
    listen: 127.0.0.1:15432         # network omitted -> tcp

  - name: appdb-uds
    network: unix                   # the only line that changes it
    listen: /run/hoop-inspect/pg.sock
```

`network` accepts `tcp` or `unix`. Anything else fails startup, naming the lane:

```
Error: invalid config:
  - appdb: network must be tcp or unix, got "udp"
```

Lanes in one process can differ, so you can move one lane to a socket without touching the other. Nothing above the transport changes: policy, masking, audit and `upstream_tls` behave the same way, because the gate reads a `net.Conn` and never asks what kind it is.

#### Choosing between them

Both carry the same traffic. They differ in who can reach the lane and what it costs to set up.

|               | Unix socket                                            | TCP port                                        |
| ------------- | ------------------------------------------------------ | ----------------------------------------------- |
| Reachable by  | whoever can open the file                              | anything that can route to the host             |
| Narrowing it  | directory and file permissions                         | a NetworkPolicy, which narrows without removing |
| Setup cost    | both processes mount one directory and agree on uids   | none                                            |
| Restart quirk | a stale file after SIGKILL, which the Sidecar reclaims | none                                            |
| Fits          | a sidecar beside one workload in one pod               | separate hosts, or a laptop                     |

A socket gives the tighter boundary: no port exists, so reachability is a filesystem question. A port gives the simpler deployment, which is why the compose stack defaults to it and keeps sockets in an overlay. Lanes in one process can differ, so you can take the socket where it is cheap and leave the port where it is not.

Keep the admin listener on TCP. It serves `/healthz` to a container healthcheck and `/stats` to a scraper, and moving it to a socket means exec-ing into the container to read either.

#### Which transport is running

Three places say it, and they agree because they read the same resolved config.

The startup log, one line per lane:

```json theme={"dark"}
{"msg":"hoop-inspect listening","listener":"appdb","network":"unix",
 "listen":"/run/hoop-inspect/pg.sock","protocol":"postgres"}
```

`GET /stats`, whose `addr` is whatever the listener bound. A path means unix, a `host:port` means TCP. This is the post-bind address, so it reflects what happened rather than what you asked for:

```json theme={"dark"}
{"listeners": [
  {"name": "appdb",   "addr": "/run/hoop-inspect/pg.sock",   "active": 0, "total": 9},
  {"name": "httpbin", "addr": "/run/hoop-inspect/http.sock", "active": 0, "total": 7}
]}
```

The filesystem, where the leading `s` marks a socket:

```bash theme={"dark"}
ls -l /run/hoop-inspect/
# srwxrwxr-x 1 10001 envoy 0 pg.sock
```

#### Two permission traps

Both cost real time, and neither produces a useful error on its own.

**Creating the socket.** The Sidecar needs write permission on the directory. A volume that mounts root-owned against a non-root image gives:

```
listen unix /run/hoop-inspect/pg.sock: bind: permission denied
```

**Connecting to it.** `connect()` on a unix socket requires **write** permission on the socket file, not read. Go creates a listening socket at `0777 &^ umask`, and the usual 022 clears exactly the group-write bit a peer needs. Envoy then fails with nothing useful in either log: `flags=UF` and an `upstream_cx_connect_fail` counter, while the cluster still reports healthy because the endpoint resolved.

Run the Sidecar with the peer's gid and `umask 0002` so its sockets come out group-writable. [`deploy/docker-compose/envoy-stack/uds/`](https://github.com/hoophq/hoop/tree/main/deploy/docker-compose/envoy-stack/uds) does exactly this and is worth reading before you write your own.

#### Stale sockets after an unclean exit

Go unlinks the socket when the listener closes, so an orderly shutdown leaves nothing behind. A SIGKILL, an OOM kill or `docker kill` skips that and the file outlives the process.

The Sidecar reclaims it. At startup it dials the path, and a socket nothing answers on gets unlinked with a warning:

```json theme={"dark"}
{"level":"WARN","msg":"removed a stale socket file left by an unclean shutdown",
 "listener":"appdb","listen":"/run/hoop-inspect/pg.sock"}
```

A socket that does answer is left alone and the bind fails, naming the conflict, because two relays sharing one socket would split a client's connections between them at random:

```
hoopinspect/proxy: /run/hoop-inspect/pg.sock is a live socket;
another relay is already listening on it
```

### How a listener inherits

| Field              | Merge                       | Why                                                                                                                                               |
| ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `guardrails.rules` | concatenate, listener first | Every rule denies and the first match wins, so concatenating cannot change the allow/deny outcome. Order only picks which message the user reads. |
| `opa`              | replace                     | One lane has one decision endpoint.                                                                                                               |
| `mask`             | replace                     | A rule owns an entity type, and two concatenated lists leave two rules competing for one entity.                                                  |

Reading the file will not tell you which rules a lane ended up with, because inheritance happens at startup. Ask the running process:

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

```json theme={"dark"}
{"lanes": [
  {"name": "appdb", "protocol": "postgres", "listen": "0.0.0.0:15432",
   "upstream": "appdb:5432",
   "rules": ["no-destructive-sql", "no-cpf-in-query"], "masking": true},
  {"name": "mssqldb", "protocol": "mssql", "listen": "0.0.0.0:11433",
   "upstream": "mssql:1433",
   "rules": ["no-destructive-tsql", "no-cpf-in-query"], "masking": true},
  {"name": "httpbin", "protocol": "http", "listen": "0.0.0.0:18080",
   "upstream": "httpbin:8080",
   "rules": ["no-admin-api", "no-internal-ids", "no-upstream-5xx",
             "no-cpf-in-query"], "masking": true}
], "version": "0.1.0"}
```

Every lane inherited `no-cpf-in-query`. None inherited another lane's rules. Rule names only: a `pattern_regex` can encode business logic, and this endpoint already sits beside a read interface to the audit trail.

***

## Guardrail rules

Each entry in `guardrails.rules` is one rule. A rule matches, and by default it **denies**. First match wins among the rules that deny. A rule set is an ordered deny list.

| `type`            | Matches when                                                              | Fields                                       |
| ----------------- | ------------------------------------------------------------------------- | -------------------------------------------- |
| `operation`       | The statement's most consequential effect is listed                       | `operations`                                 |
| `table`           | The statement references a listed relation                                | `tables`, `access`, `require_table_match`    |
| `pattern_match`   | An RE2 regex matches the statement text                                   | `pattern_regex`                              |
| `deny_words_list` | The text contains any word, case-insensitively                            | `words`                                      |
| `http_resource`   | The normalized request path matches                                       | `resources`, `methods`                       |
| `http_status`     | The response status matches                                               | `statuses`, `methods`                        |
| `pii`             | The detector finds a listed entity in the statement                       | `entities`                                   |
| `ai_analysis`     | A language model classifies the statement as a risk you mapped to `block` | `trigger`, `high`, `medium`, `low`, `prompt` |

Every rule also takes `name`, `message` and `action`. `message` is what the user reads on denial, delivered in the protocol's own error frame. Leave it empty and the rule falls back to a generated string naming only the rule and operation; write one.

`action` takes `defer` or nothing. Empty denies, the behavior every rule had before findings existed. `defer` reports the match as a finding and lets a Rego policy rule on it, so the matching stays in the local engine and only the determination moves:

```yaml theme={"dark"}
- name: cpf
  type: pii
  entities: [BR_CPF]
  action: defer      # report a finding; the decide-phase policy rules on it
```

Deferring does not stop the rule set. First match wins applies to denials, so a deferring rule records and evaluation continues, and a hard rule further down still denies. [Guardrail Rules](/docs/setup/configuration/hoop-sidecar/policy-rules) covers the producer model, the finding shape and the Rego that reads it.

An HTTP rule never matches a SQL statement and vice versa, so one mixed rule set cannot deny the wrong protocol.

<Note>
  `ai_analysis` is the one type the local rules engine does not evaluate. It is lifted out of the set at startup and runs after the local rules and OPA, so its position in the list has no effect. It also needs an [`analyzer`](/docs/setup/configuration/hoop-sidecar/risk-analysis) section, and on an HTTP lane it needs `http.capture_body: true`. Startup refuses a lane missing either. An `ai_analysis` rule spells deferring per risk level (`high: defer`) rather than through `action`.
</Note>

### operation reports the worst effect

`Operation` is the most consequential effect of the statement, not the verb the user typed. `WITH d AS (DELETE FROM customers RETURNING *) SELECT count(*) FROM d` reports `delete`, so a rule naming `delete` catches it. `EXPLAIN ANALYZE DELETE FROM customers` reports `delete` as well, because it runs the statement; plain `EXPLAIN` reports `explain` and reads the table.

The scanner discards comments and string literals before it classifies, so `SELECT 'DROP TABLE customers'` is a `select`. A word list denies that harmless query.

```yaml theme={"dark"}
- name: no-destructive-sql
  type: operation
  operations: [drop, delete, truncate]
  message: destructive statements are not permitted on appdb
```

Add `unknown` to that list on a lane where a statement the scanner could not read must not run. For anything one verb describes too coarsely, `input.effects` and `input.relations` carry the full picture to Rego; see [Guardrail Rules](/docs/setup/configuration/hoop-sidecar/policy-rules).

SQL operations: `select`, `insert`, `update`, `delete`, `merge`, `create`, `drop`, `alter`, `truncate`, `grant`, `revoke`, `call`, `copy`, `explain`, `show`, `set`, `begin`, `commit`, `rollback`. Two more carry a meaning of their own: `other` is a statement that parsed into none of those, and `unknown` is one the scanner could not finish, with the reason in `metadata["sql.incomplete"]`. HTTP verbs are distinct values: `get`, `post`, `put`, `patch`.

<Warning>
  **Breaking change: `CALL` and `EXECUTE` report `unknown`.** Their bodies live in the catalog, so no parser can say what they touch, and reporting `call` claimed knowledge the Sidecar does not have. A rule written as `operations: [call]` **stops matching them**. Write `operations: [call, unknown]`.

  `call` survives in the vocabulary and costs nothing to name, though no complete statement reports it as an operation today: `CALL`, `EXECUTE`, `EXEC` and `DO` all come back incomplete. Rego still sees `call` in `input.effects`.

  `unknown` covers those four and any other statement the scan could not finish. Naming it is the fail-closed choice, and it stays the right one permanently: no parser reads a body that lives in the catalog, PostgreSQL's own included.
</Warning>

### http\_resource matches a normalized path

`/anything/users/12345/orders/98765` normalizes to `/anything/users/*/orders/*`, so one rule replaces a regex per endpoint. A trailing `/**` matches any deeper path.

```yaml theme={"dark"}
- name: no-admin-api
  type: http_resource
  resources: ["/admin/**"]
  message: the admin API is not reachable through this proxy
```

Short slugs survive normalization intact: merging `/users/alice` with `/users/settings` would widen every rule written against either, with no signal that it happened. The normalizer errs toward keeping segments, so a policy comes out too narrow rather than too broad.

### http\_status is the rule ext\_authz cannot express

`ext_authz` decides before Envoy calls the upstream, so no Envoy configuration can read a response status. This one reads it:

```yaml theme={"dark"}
- name: no-upstream-5xx
  type: http_status
  statuses: ["5xx"]     # exact codes ("404") and classes ("4xx") both work
  message: upstream failure suppressed by a guardrail
```

### pii is a guardrail, not masking

Masking rewrites the response. A national ID in a `WHERE` clause has already landed in the database's own query log, slow-query log and `EXPLAIN` output, and no amount of response masking undoes that. Deny it on the way in:

```yaml theme={"dark"}
- name: no-cpf-in-query
  type: pii
  entities: [BR_CPF]
  message: do not put a taxpayer id in a query; it lands in the database's own logs
```

The denial message never quotes the value it found. A message quoting the identifier it denied has published that identifier.

### table rules split read from write

`tables` names relations, and `access` narrows the rule to `write` or `read`. Leave `access` unset and the rule matches either, which is what every rule written before the split meant, so deployed rules behave as they did:

```yaml theme={"dark"}
- name: protect-customers
  type: table
  tables: [customers]
  access: write                # read is the other value; unset matches both
  message: nothing may write to the customer ledger
```

Without `access`, "nothing writes to customers" has to be spelled "nothing mentions customers", so `INSERT INTO staging SELECT * FROM customers` trips a rule it only reads through, and operators widen the rule until it protects nothing. A value other than `read` or `write` is refused at startup.

Relations come from a scanner rather than a full SQL grammar. A statement it cannot read comes back with no relations and an `unknown` operation, and empty means "could not determine":

```yaml theme={"dark"}
- name: no-payroll
  type: table
  tables: [salaries]
  require_table_match: true    # also deny when the relations could not be determined
  message: the payroll tables are not reachable through this proxy
```

Set `require_table_match: true` on rules protecting something critical, and accept the false positives.

***

## OPA

`opa` is its own section, at the top level or on a listener, and it is the only OPA-specific configuration in the file. A lane consults its endpoint after its guardrails pass, so a statement the local set already forbids costs no network round trip.

```yaml theme={"dark"}
opa:
  url: http://opa:8181/v1/data/hoop/inspect
  timeout_sec: 2
  fail_open: false
  gate: false          # true adds an earlier decision; see Two phases below
```

| Field         | Meaning                                                                                |
| ------------- | -------------------------------------------------------------------------------------- |
| `url`         | The OPA Data API endpoint. Required; an `opa` block without one is refused at startup. |
| `timeout_sec` | Deadline for one decision.                                                             |
| `fail_open`   | `false`, the default, denies when the endpoint cannot answer.                          |
| `gate`        | Adds an earlier decision phase. See [Two phases](#two-phases).                         |

The Sidecar does not own policy; it owns the input document:

```json theme={"dark"}
{"input": {
  "protocol": "postgres",
  "direction": "client",
  "operation": "delete",
  "tables": ["customers"],
  "effects": ["delete", "select"],
  "relations": [{"name": "customers", "access": "write"}],
  "phase": "decide",
  "findings": {
    "pii": {"rule": "no-cpf", "status": "ok", "reason": "",
            "values": {"entities": ["BR_CPF"], "rules": ["no-cpf"]}}
  },
  "context": {"principal": "alice", "session_id": "98203ccc",
              "connection": "appdb"}
}}
```

`operation` is the worst effect and `tables` the flattened relation names, both unchanged. `effects` and `relations` say what those two could not: a data-modifying CTE both deletes and selects, and a flat name list cannot separate the table a statement writes from the one it reads. `findings` carries what each deferring rule established, keyed by producer source. A lane with no producers sends a byte-identical document to the one it sent before findings existed.

`context` comes from the session: `principal`, `session_id` and `connection`, plus `subject`, `email`, `groups`, `peer_addr`, `upstream` and `correlation_id` where the identity carries them.

The document also carries `statement` (the text verbatim), `database`, `metadata` from the codec, and `http` with the method, path and normalized resource on an HTTP lane.

Your Rego may answer `{"allow": bool}`, `{"denied": bool}`, or a bare boolean, with an optional `message` and `rule`.

<Warning>
  OPA **fails closed**. An unreachable endpoint, a 500, or an undefined decision denies the statement. Set `fail_open: true` only where availability outranks enforcement. The gate phase below is the one exception, and it is deliberate.
</Warning>

### Two phases

`gate: true` adds a decision **before** the producers run, so a policy answers "is this statement worth a model call" before anyone pays for one. Both calls hit the same URL and carry `input.phase`, so a policy that ignores the field answers both identically and turning the gate on costs one round trip.

| Lane                      | Chain                                                  |
| ------------------------- | ------------------------------------------------------ |
| `gate: true`              | `guardrails` → `opa(gate)` → producers → `opa(decide)` |
| Something defers, no gate | `guardrails` → producers → `opa(decide)`               |
| Neither                   | `guardrails` → `opa`                                   |

The gate answers with its allow/deny plus a `request` map keyed by producer source:

```json theme={"dark"}
{"result": {"allow": true, "request": {"ai_analysis": true}}}
```

`true` runs a producer its own configuration would have skipped, `false` vetoes one it would have run, and an absent key leaves the config 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>

<Warning>
  A single-call lane sends **no** `phase` field, so Rego testing `input.phase == "decide"` is undefined there and `fail_open: false` denies everything. Write `phase := object.get(input, "phase", "decide")` and the same policy serves both arrangements.
</Warning>

See [Guardrail Rules](/docs/setup/configuration/hoop-sidecar/policy-rules) for the finding shape, the status vocabulary and worked Rego.

***

## Masking

Masking runs on responses only. Requests are never rewritten: changing the statement the upstream executes is a correctness change wearing a privacy label. A `mask` block carrying rules is on; there is no separate switch.

```yaml theme={"dark"}
mask:
  rules:
    - {name: emails, entities: [EMAIL_ADDRESS], strategy: redact}
    - {name: cards, entities: [CREDIT_CARD], strategy: partial, keep_last: 4}
    - {name: ids, entities: [US_SSN, BR_CPF], strategy: hash}
    - {name: ssn-column, columns: [ssn], strategy: partial, keep_last: 4}
```

| Field       | Meaning                                                                                                        |
| ----------- | -------------------------------------------------------------------------------------------------------------- |
| `entities`  | Entity types to rewrite wherever they appear. **A list**, even for one type. Required unless `columns` is set. |
| `columns`   | Result-set column names to mask outright, compared case-insensitively.                                         |
| `strategy`  | `redact`, `mask`, `partial` or `hash`. Empty means `redact`.                                                   |
| `keep_last` | Tail length for `partial`. Default 4.                                                                          |
| `mask_char` | Replacement rune for `mask` and `partial`. Default `*`.                                                        |

| Strategy  | `4111111111111111` becomes                                                                             |
| --------- | ------------------------------------------------------------------------------------------------------ |
| `redact`  | `[REDACTED:CREDIT_CARD]`                                                                               |
| `mask`    | `****************`                                                                                     |
| `partial` | `************1111`                                                                                     |
| `hash`    | `sha256:<first 16 hex>`. Equal inputs give equal outputs, so a masked column still works as a join key |

One rule may name several entity types, and they share its strategy. Types needing different strategies need one rule each:

```yaml theme={"dark"}
- {name: ids, entities: [US_SSN, BR_CPF], strategy: redact}       # one rule, two types
```

### Entity rules versus column rules

An **entity rule** masks by detection and applies anywhere, including inside an opaque HTTP body. A **column rule** masks by position: it works only where the protocol names its values, and there it beats detection outright, because the column does not care what the value looks like.

The difference is observable. The seeded value `123-45-6789` is one the detector refuses, rejecting sequential digit runs as obvious test fixtures:

```
postgres  SELECT ssn FROM customers   →  ***-**-6789   column rule caught it
HTTP      POST {"x":"123-45-6789"}    →  123-45-6789   no detection, no column
```

A validating detector cuts false positives on ordinary numeric ids and declines the placeholders. A column rule covers the gap wherever the protocol gives you a name to key on.

### Naming your entities

**Omit `pii` and every supported entity is enabled.** Nothing has to be declared before a `mask` rule or a `pii` guardrail can name a type, which is the whole point of the default: a config that names `US_SSN` in one place should not also have to name it in another.

Set the section to narrow the engine, or to tune it:

```yaml theme={"dark"}
pii:
  entities: [EMAIL_ADDRESS, US_SSN, CREDIT_CARD, BR_CPF, IBAN_CODE]
  # ignored: []      # entity types to drop from the engine's output
  # threshold: 0.0   # minimum detection confidence
  # allow_list: []   # literal values never reported
```

Naming `entities` makes the list exhaustive: a rule naming a type outside it is refused at startup, so narrowing the engine cannot silently disarm a rule that reads as live.

<Warning>
  The permissive default costs recall nothing and precision something. Some recognizers carry a real checksum — card (Luhn), CPF (mod-11), IBAN (ISO 7064) — and leave lookalike ids alone. `US_SSN` carries none, so nine digits in a legal range is a valid SSN as far as any detector can tell:

  ```
  {"order_id":457555462,"customer_id":123456781}
    → both reported as US_SSN
  ```

  That only reaches a client through a rule you wrote: masking rewrites the types a `mask` rule names, and nothing else. Where a lane carries numeric ids that look like an identifier, prefer a `columns` rule to an entity rule, and set `pii.entities` to what the data actually holds.
</Warning>

<Note>
  Masking needs a codec that can carry it. HTTP declares its body length in a header the Sidecar corrects; Postgres rebuilds its length-prefixed row frames around the new values, and MSSQL reassembles the TDS token stream and lays fresh packets over the rewritten rows. A protocol offering neither gets its `mask` section **refused at startup**. Accepting a mask config that can never fire is the failure that ends with an unmasked SSN in a screenshot.
</Note>

***

## Licensing

Unlicensed, the Sidecar enforces **one guardrail rule and one data masking rule**. The cap is per process, not per lane: one rule in the top-level `guardrails` block counts once however many listeners inherit it, and `mask.rules` on a lane counts against the same budget as the default block it replaces. `ai_analysis` rules are counted apart and never capped, because their controls are the trigger and `analyzer.max_calls` rather than a number of rules.

A license Hoop signed lifts the caps for the features it names.

### Three places carry a license

| Precedence | Source       | Spelling                                                                                |
| ---------- | ------------ | --------------------------------------------------------------------------------------- |
| 1          | Command line | `hoop start sidecar --license …`, or `hoop-inspect -license …` on the standalone binary |
| 2          | Environment  | `HOOP_LICENSE`                                                                          |
| 3          | Config file  | the top-level `license` key                                                             |

`HOOP_LICENSE` is not `HOOP_SIDECAR_LICENSE`: the same document Hoop issues verifies on the gateway too, and a deployment that spells it two ways sets one of them wrong. Installing it on the gateway is a separate step, covered in [License Management](/docs/setup/license-management).

### A path or the document itself

All three sources take the same two shapes. A value whose first non-blank character is `{` is read as the license document; anything else is read as a filename. One field serves a mounted secret and an inline value, so moving a license between the two is not also a rename.

```yaml theme={"dark"}
license: /etc/hoop-inspect/license.json
```

```yaml theme={"dark"}
license: '{"payload":{"type":"enterprise","description":"Acme Corp",...},"key_id":"743420a…","signature":"…"}'
```

The same holds for the flag and the environment variable, which is what makes a Kubernetes secret work without a volume:

```bash theme={"dark"}
hoop start sidecar --config config.yaml --license /etc/hoop-inspect/license.json
HOOP_LICENSE="$(cat license.json)" hoop start sidecar --config config.yaml
```

<Warning>
  **First wins, not first valid.** A `HOOP_LICENSE` pointing at a file that does not exist is an error, not a reason to fall back to the `license` key. A process that quietly ignored your environment variable would surprise you on the restart after somebody edits the config file.
</Warning>

### What a license lifts

Each feature named in the license lifts its own cap. The Sidecar reads two of them, `guardrails` and `data-masking`. A license naming neither covers both; one naming only `data-masking` leaves the guardrail cap where it was. An `oss` license verifies and grants nothing, matching what the control plane does with the same document.

### What the process concluded

One line, first in the startup log and first in `--validate`:

```
license: valid. enterprise "Acme Corp", expires 2027-01-30, features: all (from HOOP_LICENSE)
license: expired. "Acme Corp" expired on 2026-05-01, running the free tier. Renew it at https://help.hoop.dev (from the license flag)
license: missing, running the free tier. Add one with the license flag, the HOOP_LICENSE environment variable, or the "license" key in the config file
```

`GET /config` on the admin listener serves the same verdict under `license` and the caps under `limits`, where `null` means the license lifted one. It never returns the signature:

```json theme={"dark"}
{
  "limits": {"guardrail_rules": null, "mask_rules": 1},
  "license": {
    "state": "valid",
    "source": "HOOP_LICENSE",
    "type": "enterprise",
    "description": "Acme Corp",
    "key_id": "743420a…",
    "expire_at": 1801526400,
    "features": ["guardrails"]
  }
}
```

Missing and expired are states, not failures: the process starts and the caps stay in force. A license that cannot be **read** stops startup instead — a path to nothing, malformed JSON, or a signature that does not verify — and the error names the source it came from. Dropping to the free tier over a typo in a path would be a silent downgrade.

### When a term ends under a running process

The verdict is a function of the clock rather than a flag set at startup, so a relay that has been up for months flips `/config` and its caps to the free tier the second its term lapses. Nothing reloads.

* **A config inside the free tier keeps serving.** Expiry took nothing from it, and stopping the relay would be an outage with no revenue behind it.
* **A config over the free tier stops.** The relay logs the expiry, drains open connections, flushes the audit trail and exits non-zero. Startup then refuses the config by name until somebody renews or removes rules.

The log counts the term down once a day through its last fortnight, so the stop is not the first warning you get:

```
WARN license expires soon, and this config needs it: the relay stops when the term ends days_left=6 expires=2026-05-01T00:00:00Z renew=https://help.hoop.dev
```

Re-applying the caps to a running relay would mean deleting rules, and that is worse than the stop. Lanes are built once and hold their rules for the life of the process; dropping a guardrail lets through statements it was refusing, and dropping a mask rule leaks the values it was hiding. A billing event must never widen what a proxy allows.

***

## Control Plane

A Sidecar that knows nothing about a Control Plane reads its config file from disk and runs standalone. Pointing it at one is two independent settings: where the Control Plane is, and the token that identifies this Sidecar to it.

| Setting           | Source       | Spelling                              |
| ----------------- | ------------ | ------------------------------------- |
| Control Plane URL | Config file  | the top-level `control_plane_url` key |
| Control Plane URL | Environment  | `HOOP_CONTROL_PLANE_URL`              |
| Token             | Command line | `hoop start sidecar --token …`        |
| Token             | Environment  | `HOOP_SIDECAR_TOKEN`                  |

The environment variable outranks the config file for the URL, the same as every other source pair in this file.

There is no config file key for the token. A token is a bearer credential for this Sidecar's identity, and a config file is the thing most likely to end up committed, backed up, or pasted into a support ticket — so the only two sources are the flag and the environment variable, never something written to disk in the file this Sidecar's own diagnostics might echo back.

```yaml config.yaml theme={"dark"}
control_plane_url: https://hoop.your-company.com
```

```bash theme={"dark"}
hoop start sidecar --config config.yaml --token "$(cat /etc/hoop/sidecar.token)"
# or
HOOP_SIDECAR_TOKEN="$(cat /etc/hoop/sidecar.token)" hoop start sidecar --config config.yaml
```

See [Connect a Sidecar](/docs/control-plane/connect-sidecar) for the handshake itself and how to issue a token.

***

## Audit

| Field                 | Default | Meaning                                                                               |
| --------------------- | ------- | ------------------------------------------------------------------------------------- |
| `file`                | none    | JSON lines destination. `"-"` is stdout, which a container deployment wants.          |
| `redact_statements`   | `false` | Replace statement text with a stable fingerprint.                                     |
| `max_statement_bytes` | `8192`  | Truncate recorded statements.                                                         |
| `async_queue_size`    | `0`     | Bounded async queue so a slow disk does not block a query. Zero writes synchronously. |
| `memory_buffer`       | `0`     | Keep the last N events readable at `GET /events`.                                     |
| `query_sessions`      | `0`     | Sessions retained for the query API. Zero disables `/api/*`.                          |
| `fail_closed`         | `false` | Deny a statement whose audit record could not be written.                             |

Six event kinds reach the sink: `session_start`, `statement`, `violation`, `masked`, `error`, `session_end`. A denial writes `violation` instead of `statement`, so a security team can select denials without scanning every statement anyone ever ran.

<Warning>
  `fail_closed: false` is the default and it is the uncomfortable one. A dropped audit write lets the statement proceed and logs the error. Set it to `true` where proving who did what matters more than staying up.
</Warning>

The Sidecar records what was masked, never the values:

```json theme={"dark"}
{"kind":"session_end","timestamp":"2026-07-30T18:53:34Z","session_id":"98203ccc…",
 "principal":"anonymous","protocol":"postgres","connection":"appdb",
 "duration_ns":11098606,"statement_count":2}
```

<Warning>
  The admin listener serves a read interface to every statement every user ran, with no authentication and no CORS of its own. Bind it to loopback or put it behind whatever already gates audit access, and never expose it on a data port.
</Warning>

***

## Upstream TLS

The hop from the Sidecar to the backend can be encrypted, and it costs you no inspection.

```yaml theme={"dark"}
listeners:
  - name: appdb
    protocol: postgres
    listen: 0.0.0.0:15432
    upstream: appdb:5432
    upstream_tls:
      ca_file: /etc/hoop-inspect/certs/appdb.crt   # omit to use the host trust store
      server_name: appdb                           # defaults to the upstream host
      # cert_file / key_file    for mTLS
      # insecure_skip_verify    logs a warning; do not ship it
```

The Sidecar is the TLS **client** on that hop, so it decrypts on read and the gate inspects plaintext the same way it does without TLS. Verify it from a client session:

```sql theme={"dark"}
SELECT ssl, version FROM pg_stat_ssl WHERE pid = pg_backend_pid();
```

```
 ssl | version
-----+---------
 t   | TLSv1.3
```

Three behaviors on a Postgres lane surprise people:

* **Postgres negotiates in-band.** The server expects an 8-byte `SSLRequest` and a one-byte reply before any handshake. The Sidecar speaks that exchange, so `upstream_tls` works the way the field name implies.
* **A refusal is an error, never a downgrade.** If the server declines TLS, the connection fails with a message naming the likely cause. Sending credentials in the clear because the server said no is the outcome you were preventing.
* **Channel binding is dropped from the server's offer.** With TLS terminating at the Sidecar, `SCRAM-SHA-256-PLUS` cannot work. The Sidecar removes that one mechanism, leaving plain `SCRAM-SHA-256`, which authenticates the same password against the same verifier.

On an MSSQL lane `upstream_tls` performs a plain TLS-on-connect handshake, which is TDS 8.0 and the only TLS shape the Sidecar originates. SQL Server on Linux cannot accept it: strict encryption is a Windows feature, and the Linux build offers `network.forceencryption`, whose TLS is the TDS 7.x kind negotiated inside `0x12` PRELOGIN packets. Leave `upstream_tls` off that lane and keep the hop on loopback or a private network.

### The client leg is a different hop

`upstream_tls` covers the Sidecar-to-backend hop only. The Sidecar terminates **no** downstream TLS: if the client negotiates TLS all the way through, there is no plaintext at the gate and inspection is impossible. Terminating that leg belongs to Envoy.

| Leg             | Encrypted by                                                                                                                                                                      |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client → Envoy  | Envoy. An HTTPS listener does it already; a Postgres lane needs `postgres_proxy` with a `starttls` transport socket, and an MSSQL lane a plain `DownstreamTlsContext` for TDS 8.0 |
| Envoy → relay   | nothing, by design. These are the bytes the gate parses                                                                                                                           |
| relay → backend | the Sidecar, via `upstream_tls` above                                                                                                                                             |

Keep the middle leg on loopback or a unix socket. It carries decrypted traffic, and a socket is the tighter boundary because no port exists to reach. See [Terminating client TLS](/docs/setup/configuration/hoop-sidecar/get-started#terminating-client-tls) for the Envoy config.

***

## Sharing a rule block between lanes

This is the reason to prefer YAML. Anchors let several listeners reference one block:

```yaml theme={"dark"}
x-readonly: &readonly
  - name: no-writes
    type: operation
    operations: [insert, update, delete, drop, truncate]
    message: this credential is read-only

listeners:
  - {name: replica-a, protocol: postgres, listen: 0.0.0.0:15432, upstream: a:5432, guardrails: {rules: *readonly}}
  - {name: replica-b, protocol: postgres, listen: 0.0.0.0:15433, upstream: b:5432, guardrails: {rules: *readonly}}
```

***

## Validate before you deploy

Nothing needs to be running:

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

```
config OK: 2 listener(s)
  license: missing, running the free tier. Add one with the license flag, the HOOP_LICENSE environment variable, or the "license" key in the config file
  limits: 1 guardrail rule(s), 1 data masking rule(s)
  appdb            postgres  enforcing 2 rule(s) + masking
  httpbin          http      enforcing 4 rule(s) + opa + masking
```

Validation builds every lane, so it catches what a syntax check cannot, and it reports every problem in one run rather than one per restart.

### What startup refuses

**A key typo, in YAML or JSON:**

```
Error: parse config: json: unknown field "logLevel"
```

**A bad regex, naming the lane and the rule:**

```
Error: invalid config:
  - appdb: guardrails: invalid rules: broken: bad pattern: error parsing regexp: missing closing ]: `[unclosed`
```

**A `pii` rule naming an entity absent from an explicit `pii.entities`:**

```
Error: invalid config:
  - appdb: rule "no-cpf" names entity "BR_CPF", which the detector is not
    configured to find; add it to pii.entities or the rule will never match
```

That last check matters more than it looks. Without it the rule loads, evaluates, and matches nothing, so a guardrail looks live while allowing through everything it was written to stop. It only fires where `pii.entities` is set: with the section omitted, every supported entity is enabled and no rule can name one that is missing.

**An `ai_analysis` rule that would classify nothing:**

```
Error: invalid config:
  - appdb: ai_analysis rule "risky-writes" has no trigger, so it would
    classify nothing; name operations, tables or resources
```

Every analyzer refusal follows the same argument, applied to a control that also costs money per statement: a rule with no trigger, a tier with no action, a provider the binary does not link, or `send: redacted` with no `pii` section all produce a lane that looks classified and is not. The full list is in [Risk Analysis](/docs/setup/configuration/hoop-sidecar/risk-analysis#what-startup-refuses).

**A rule that defers to nothing:**

```
Error: invalid config:
  - appdb: rule "cpf" defers its match to a policy decision, and the lane has
    no opa.url to defer to; set one or drop the action
```

**A gate over nothing:**

```
Error: invalid config:
  - appdb: opa.gate is on but the lane has no ai_analysis rule, so the
    extra decision would gate nothing
```

**A config over the free-tier caps**, naming every block that authored rules so the message reads as a map of what to merge:

```
Error: invalid config:
  - 2 guardrail rules are configured (guardrails: 1, appdb: 1) and this
    process enforces at most 1; merge them into one rule. A license lifts
    this cap: add one with the license flag, the HOOP_LICENSE environment
    variable, or the "license" key in the config file. Contact our support
    at https://help.hoop.dev. ai_analysis rules are counted separately and
    are not limited
```

This one is checked after the license resolves rather than while the file parses, so a config a license makes legal is never refused before anyone read the license. See [Licensing](#licensing).

Four more refusals arrived with findings, each one a config that would load and then mean something other than what it says:

| Config                                         | Refusal                                                                                                                         |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `action` set to anything but `defer`           | Unknown action, naming the rule. Empty denies; `defer` reports a finding.                                                       |
| `action` set at all on an `ai_analysis` rule   | That type defers per risk level through `high`, `medium` and `low`, and two spellings would leave two answers for one question. |
| `access` set to anything but `read` or `write` | Unknown access, naming the rule. Empty matches either.                                                                          |
| A risk level set to `defer` with no `opa.url`  | Same argument as a local rule deferring to nothing: use `block`, `warn` or `allow`.                                             |

**A `mask` block on a protocol whose codec can carry neither masking mechanism**, naming the lane, plus a `mask.rules` entry with neither `entities` nor `columns`, empty rule lists, duplicate listen addresses, missing upstreams and an `opa` block with no URL.

***

## Migrating from `policy`

The old `policy` section split into `guardrails` and `opa`, and four fields went away. Deprecated spellings still load and log a warning at startup, so an existing file keeps working; they are not part of the specification above and will be removed. Do not write both spellings of one concept into the same file.

| Deprecated               | Replacement                      | Notes                                                                                 |
| ------------------------ | -------------------------------- | ------------------------------------------------------------------------------------- |
| `policy.rules`           | `guardrails.rules`               | Same rule types, same fields, same first-match-wins.                                  |
| `policy.opa`             | `opa`, top level or per listener | Same fields. It is now a sibling of `guardrails`, not a child.                        |
| `policy.enforce`         | *(removed)*                      | Every lane enforces.                                                                  |
| `mask.enabled`           | *(removed)*                      | A `mask` block with rules masks.                                                      |
| `mask.rules[].entity`    | `mask.rules[].entities`          | A list, even for one type. `entity: US_SSN` becomes `entities: [US_SSN]`.             |
| `listeners[].connection` | *(removed)* — use `name`         | The listener's `name` is the label audit events and `input.context.connection` carry. |
| `pii` *(required)*       | `pii` *(optional)*               | Omitted, every supported entity is enabled.                                           |

Three of those changes alter behavior rather than spelling:

* **Enforcement is unconditional.** A lane that used to run with `enforce: false` starts denying the moment the field is dropped. Read `/api/events?kind=violation` on the observe-only deployment first, and move rules you are still evaluating to a staging listener or to `action: defer` behind a policy that allows them.
* **A `mask` block is live on sight.** A block left in place with `enabled: false` used to be inert. Delete the rules to keep it off.
* **PII detection is on by default.** A config that omitted `pii` detected nothing; the same file now enables every supported entity. Detection alone changes no traffic, since masking rewrites only what a `mask` rule names, but `analyzer.send: redacted` now has a detector to work with and `pii` guardrail rules can name any supported type.

`/config` no longer reports an `enforcing` flag per lane, and `--validate` no longer prints `observe-only`.

***

## Next

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

  <Card title="Get Started" icon="rocket" href="/docs/setup/configuration/hoop-sidecar/get-started">
    Configure Envoy, run the Sidecar, and watch a denial land in psql.
  </Card>

  <Card title="Components and Architecture" icon="sitemap" href="/docs/setup/configuration/hoop-sidecar/components">
    How a request flows through the Sidecar, and where each config knob takes effect.
  </Card>
</CardGroup>
