.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-levelpolicy 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.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: omitnetwork and you get a port.
network accepts tcp or unix. Anything else fails startup, naming the lane:
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 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: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:
s marks a socket:
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: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/ 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 ordocker 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:
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:
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.
Policy rules
A rule matches, and by default it denies. First match wins among the rules that deny. A rule set is an ordered deny list.
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:
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 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.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.
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 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.
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.
/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 aWHERE 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:
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:
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”:
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.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.
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.
The gate answers with its allow/deny plus a
request map keyed by producer source:
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.
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.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 value123-45-6789 is one the detector refuses, rejecting sequential digit runs as obvious test fixtures:
Naming your entities
pii.entities is required and there is no all-entities default:
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.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.
The Sidecar records what was masked, never the values:
Upstream TLS
The hop from the Sidecar to the backend can be encrypted, and it costs you no inspection.- Postgres negotiates in-band. The server expects an 8-byte
SSLRequestand a one-byte reply before any handshake. The Sidecar speaks that exchange, soupstream_tlsworks 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-PLUScannot work. The Sidecar removes that one mechanism, leaving plainSCRAM-SHA-256, which authenticates the same password against the same verifier.
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.
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:What startup refuses
A key typo, in YAML or JSON:pii rule naming an entity absent from pii.entities:
ai_analysis rule that would classify nothing:
send: redacted with no pii section all produce a lane that looks classified and is not. The full list is in Risk Analysis.
A rule that defers to nothing:
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
Policy Rules
Every rule type in depth, deferring to Rego, and the findings a policy reads.
Get Started
Configure Envoy, run the Sidecar, and watch a denial land in psql.
Components and Architecture
How a request flows through the Sidecar, and where each config knob takes effect.