> ## Documentation Index
> Fetch the complete documentation index at: https://hoopdev-docs-control-plane-owns-listeners.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Guardrails

> An ordered deny list evaluated against every statement, before it reaches the resource.

Someone runs this against production:

```sql theme={null}
UPDATE users SET email = 'test@example.com';
```

The `WHERE` clause is missing. 500,000 rows change. The statement was valid, the credentials were valid, the permissions were valid — nothing in the database's own model of correctness had an objection.

Guardrails are the objection. Every statement is decoded and evaluated before it reaches the resource, and a rule that matches refuses it with a message you wrote.

<Note>
  **Free tier:** one Data Masking rule and one Guardrail per Sidecar are free, forever. Running more than one rule per feature, or managing rules centrally across Sidecars, requires [Enterprise](https://hoop.dev/start).
</Note>

***

## How a rule set resolves

Three sentences cover the entire semantics:

1. A rule matches, and by default it **denies**.
2. **First match wins** among the rules that deny.
3. A rule set is an ordered deny list. No match means allowed.

```yaml config.yaml theme={null}
guardrails:
  rules:
    - name: no-destructive-sql
      type: operation
      operations: [drop, delete, truncate]
      message: destructive statements are not permitted on this listener
```

Every rule takes `name`, `message` and `action` in addition to its own fields.

<Note>
  `guardrails` is Hoop's own rule engine, evaluated in-process. It was previously spelled `policy`, which still loads and warns at startup — see [Migrating from `policy`](/setup/configuration/hoop-sidecar/config-file#migrating-from-policy). The separate `opa` section configures the optional external endpoint a `defer` hands to.
</Note>

<Warning>
  **A rule set that reaches a listener enforces.** There is no observe-only switch to forget: a rule you wrote denies from the moment the process starts. Roll out on a staging lane, or hand the call to Rego with `action: defer`.
</Warning>

***

## Rule types

| `type`            | Matches on                                       | Protocols |
| ----------------- | ------------------------------------------------ | --------- |
| `operation`       | the statement's most consequential effect        | SQL, HTTP |
| `table`           | the relation touched, narrowed by read or write  | SQL       |
| `deny_words_list` | a case-insensitive substring of the raw text     | any       |
| `pattern_match`   | an RE2 regex over the statement text             | any       |
| `pii`             | entity classes a detector finds *in the request* | any       |
| `http_resource`   | the normalized request path                      | HTTP      |
| `http_status`     | the response status                              | HTTP      |
| `ai_analysis`     | risk a language model reports                    | any       |

<AccordionGroup>
  <Accordion title="operation — deny by effect">
    ```yaml theme={null}
    - name: no-destructive-sql
      type: operation
      operations: [drop, delete, truncate]
      message: destructive statements are not permitted on this listener
    ```

    `operation` reads the statement's **worst effect**, not its leading verb. A delete hidden in a CTE — `WITH d AS (DELETE FROM customers RETURNING *) SELECT count(*) FROM d` — reports `delete` and this rule catches it. `EXPLAIN DELETE …` reports `explain`; `EXPLAIN ANALYZE DELETE …` reports `delete`, because it runs.

    Vocabulary: `select insert update delete merge create drop alter truncate grant revoke call copy explain show set begin commit rollback`, plus `other` (parsed but unclassified) and `unknown` (the scanner could not finish). HTTP verbs are their own values: `get post put patch head options connect trace`.
  </Accordion>

  <Accordion title="table — deny by relation">
    ```yaml theme={null}
    - name: customers-is-read-only
      type: table
      tables: [customers]
      access: write
      message: customers is read-only through this listener
    ```

    `access` is `read` or `write`. Add `require_table_match: true` to also deny when the relations could not be determined — the fail-closed posture for anything genuinely off limits.
  </Accordion>

  <Accordion title="deny_words_list — deny by substring">
    ```yaml theme={null}
    - name: no-admin-functions
      type: deny_words_list
      words: [pg_sleep, pg_terminate_backend]
      message: administrative functions are not available here
    ```

    Case-insensitive, matched against the raw statement text.
  </Accordion>

  <Accordion title="pattern_match — deny by regex">
    ```yaml theme={null}
    - name: no-unqualified-delete
      type: pattern_match
      pattern_regex: '(?i)^\s*delete\s+from\s+\w+\s*;?\s*$'
      message: a DELETE on this listener needs a WHERE clause
    ```

    RE2 syntax — no lookaround, no backreferences. A bad regex is rejected at startup, naming the listener and the rule.
  </Accordion>

  <Accordion title="pii — deny sensitive values in the request">
    ```yaml theme={null}
    - name: no-national-id-in-query
      type: pii
      entities: [BR_CPF, US_SSN]
      message: do not put a national ID in a query; it lands in the database's own logs
    ```

    Every supported entity is detected unless a top-level `pii.entities` list narrows the set. Once that list exists it is exhaustive, and a rule naming an entity absent from it is **refused at startup** — otherwise the guardrail would look live while allowing through everything it was written to stop.
  </Accordion>

  <Accordion title="http_resource and http_status">
    ```yaml theme={null}
    - name: no-admin-api
      type: http_resource
      resources: ["/admin/**"]
      methods: [POST, DELETE]
      message: the admin API is not reachable through this proxy
    ```

    ```yaml theme={null}
    - name: no-upstream-5xx
      type: http_status
      statuses: ["5xx"]
      message: upstream failure suppressed by this listener
    ```

    `http_status` is response-side, which is why an authorization filter running before the upstream can never ask it.
  </Accordion>

  <Accordion title="ai_analysis — deny by classified risk">
    ```yaml theme={null}
    - name: risky-writes
      type: ai_analysis
      trigger: {operations: [update, delete]}
      high: block
      medium: warn
    ```

    This is the [Agentic Access](/features/agentic-access) path. It takes per-risk-level actions instead of an `action` field, and setting `action` on it is refused at startup.
  </Accordion>
</AccordionGroup>

<Warning>
  `CALL` and `EXECUTE` report `unknown` rather than `call`, because their bodies live in the catalog and no parser can say what they touch. A rule written `operations: [call]` matches neither. Write `operations: [call, unknown]`.
</Warning>

***

## Actions

`action` on a regular rule is either empty or `defer`. That is the whole list.

| `action`  | Effect                                                           |
| --------- | ---------------------------------------------------------------- |
| *(empty)* | Deny. First match wins.                                          |
| `defer`   | Record a finding, keep evaluating, and hand the decision to OPA. |

```yaml theme={null}
- name: cpf
  type: pii
  entities: [BR_CPF]
  action: defer          # OPA rules on it
```

`defer` with no `opa.url` configured is refused at startup — a finding nobody reads forwards every statement while looking like enforcement.

<Note>
  `action: warn` and `require_review` are **refused at startup**. `warn` exists only as a per-tier action on `ai_analysis` rules. Human review needs a review backend the current build does not ship — see [Agentic Access](/features/agentic-access#the-tools). For everything else, `defer` is how a rule stops short of deciding.
</Note>

***

## Inheritance: guardrail rules concatenate

A listener's rules are evaluated **first**, then the top-level defaults:

```yaml config.yaml theme={null}
guardrails:                      # 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

listeners:
  - name: appdb
    protocol: postgres
    listen: 0.0.0.0:15432
    upstream: appdb:5432
    guardrails:
      rules:                     # runs before the inherited rule above
        - name: no-destructive-sql
          type: operation
          operations: [drop, delete, truncate]
          message: destructive statements are not permitted on appdb
```

Concatenation is safe here precisely because first-match-wins applies to denials: adding rules can never turn a deny into an allow, only change which message the user reads. `opa` replaces rather than merges, and [`mask` replaces too](/features/data-masking#inheritance-a-listeners-mask-block-replaces-the-defaults).

***

## Roll out without breaking anything

<Steps>
  <Step title="Validate the config">
    Nothing binds and nothing is denied. Startup refusals — a bad regex, a rule naming an entity outside `pii.entities`, a `defer` with no `opa.url` — surface here rather than in front of users.

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

    ```
    config OK: 1 listener(s)
      appdb            postgres  3 rule(s)
    ```
  </Step>

  <Step title="Run the rule set on a staging lane first">
    Point a listener carrying the new rules at a staging copy of the upstream and send real query shapes through it. This is what replaces an observe-only flag: the rules are live, but on a lane whose denials cost nothing.
  </Step>

  <Step title="Read what got denied">
    ```bash theme={null}
    curl -s 'localhost:19000/api/events?kind=violation' | python3 -m json.tool
    ```

    Each `violation` names the rule that fired and the message the client read, so an over-broad rule is visible by name before it reaches production.
  </Step>

  <Step title="Defer anything you are not ready to decide">
    A rule with `action: defer` records a finding and hands the call to [OPA](/setup/configuration/hoop-sidecar/policy-rules#deferring-to-opa) instead of denying on its own. Rego that returns `allow` while you watch the findings gives you the same visibility on the production lane.
  </Step>

  <Step title="Move production traffic to the lane">
    Nothing changes in the rule set. `/config` reports the resolved rules per listener, which is what to compare against the file when a rule you wrote never fires.
  </Step>
</Steps>

***

<Note>
  Looking for guardrails on the **Hoop Gateway** instead? That is a different implementation — Python regex patterns configured in the web app, with Block, Warn and Require Approval actions. See [Guardrails](/learn/features/guardrails).
</Note>

## Next

<CardGroup cols={2}>
  <Card title="Guardrail Rules Reference" icon="scale-balanced" href="/setup/configuration/hoop-sidecar/policy-rules">
    Every field of every rule type, deferring to Rego, and the full findings vocabulary.
  </Card>

  <Card title="Data Masking" icon="mask" href="/features/data-masking">
    Control what comes back, not just what goes in.
  </Card>
</CardGroup>
