Skip to main content
One file is the whole configuration. The relay 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.
New to hoop-inspect? Start with Get Started, which builds a working config one step at a time.

Shape of the file

Top-level policy and mask are defaults. Each listener is one upstream and inherits those defaults unless it overrides them.
config.yaml

Top-level sections

Keys starting x- are dropped before validation, so a YAML anchor block does not need a matching config field. See sharing rules between lanes.

Listeners

Each listener is one Envoy cluster’s worth of traffic with its own enforcement stack.
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.
Leave idle_timeout_sec unset for interactive sessions. psql idles between keystrokes, and a short value disconnects a developer mid-thought.

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.
network accepts tcp or unix. Anything else fails startup, naming the lane:
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. A socket gives the tighter boundary: no port exists, so reachability stops being a network 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:
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:
The filesystem, where the leading s marks a socket:

Two permission traps

Both cost real time, and neither produces a useful error on its own. Creating the socket. The relay needs write permission on the directory. A volume that mounts root-owned against a non-root image gives:
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 relay with the peer’s gid and umask 0002 so its sockets come out group-writable. 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 relay reclaims it. At startup it dials the path, and a socket nothing answers on gets unlinked with a warning:
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:

How a listener inherits

Reading the file will not tell you which rules a lane ended up with, because inheritance happens at startup. Ask the running process:
Both lanes inherited no-cpf-in-query. Neither inherited the other’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.

Policy rules

Every rule denies, and the first match wins. A rule set is an ordered deny list, not an allow list. Every rule also takes name and message. 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. An HTTP rule never matches a SQL statement and vice versa, so one mixed rule set cannot deny the wrong protocol.

operation, not deny_words_list

operation comes from a lexer that strips comments and string literals before looking for a verb, so SELECT 'DROP TABLE customers' classifies as a select. A word list denies that harmless query.
Valid SQL operations: select, insert, update, delete, create, drop, alter, truncate, grant, revoke, call, show, set, begin, commit, rollback. HTTP verbs are distinct values: get, post, put, patch.

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

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:
The denial message never quotes the value it found. A message quoting the identifier it denied has published that identifier.

table matching is best effort

tables comes from a lexer, not a SQL grammar. Empty means “could not determine” and never “touches nothing”:
Set require_table_match: true on rules protecting something critical, and accept the false positives.

OPA

A lane can consult an OPA Data API endpoint after its local rules pass, so a statement the local set already forbids costs no network round trip.
The relay does not own policy; it owns the input document:
Your Rego may answer {"allow": bool}, {"denied": bool}, or a bare boolean, with an optional message and rule.
OPA fails closed. An unreachable endpoint, a 500, or an undefined decision denies the statement. Set fail_open: true only where availability outranks enforcement.

Masking

Masking runs on responses only. Requests are never rewritten: changing the statement the upstream executes is a correctness change wearing a privacy label.

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

pii.entities is required and there is no all-entities default:
Turning on every recognizer rewrites ordinary numeric columns:
Nine digits in a legal range is a valid SSN as far as any detector can tell, because SSNs carry no checksum. Card, CPF and IBAN carry real checksums (Luhn, mod-11, ISO 7064), so those three leave lookalike ids alone.
Masking needs a codec that can carry it. HTTP declares its body length in a header the relay corrects; Postgres rebuilds its length-prefixed row frames around the new values. 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.

Audit

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.
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.
The relay records what was masked, never the values:
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.

Upstream TLS

The hop from the relay to the backend can be encrypted, and it costs you no inspection.
The relay 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:
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 relay 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 relay, SCRAM-SHA-256-PLUS cannot work. The relay removes that one mechanism, leaving plain SCRAM-SHA-256, which authenticates the same password against the same verifier.

The client leg is a different hop

upstream_tls covers the relay-to-backend hop only. The relay 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. 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 for the Envoy config.

Sharing a rule block between lanes

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

Validate before you deploy

Nothing needs to be running:
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:
A bad regex, naming the lane and the rule:
A pii rule naming an entity absent from pii.entities:
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. mask.enabled on a protocol whose codec can carry neither masking mechanism, naming the lane, plus empty rule lists, duplicate listen addresses, missing upstreams and an opa block with no URL.

Next

Get Started

Configure Envoy, run the relay, and watch a denial land in psql.

Components and Architecture

How a request flows through the relay, and where each config knob takes effect.