config.yaml controls which behavior.
For the commands, start at Get Started. For every config field, see the Config File reference.
The two tiers
Envoy owns TLS and the network path. OPA answers reachability.hoop-inspect reads the payload.
Envoy sees less on each lane going down the table.
On HTTP,
ext_authz handles request-side authorization well. The gap sits on the response: ext_authz decides before Envoy calls the upstream, so no configuration of it reaches a response status or a response body. On Postgres the gap is wider. Envoy forwards bytes it cannot parse, so OPA never receives a statement to judge.
Where Envoy ends and the relay begins
Envoy is not blind here, and an argument that says otherwise loses a technical review. The relay covers what remains.
One process, one lane per Envoy cluster
hoop-inspect serves one listener per Envoy cluster. Each listener resolves its own rules, its own masking and its own OPA endpoint.
net.Conn and never asks what kind it is.
Most sidecars run one lane. A per-user pod fronting both a database and an API runs two in one process instead of two containers. The relay resolves the merge between top-level defaults and a lane’s overrides once at startup, and reports every broken lane in one run.
Inside one connection
The relay accepts a socket, builds per-connection state, and starts two goroutines that pump in opposite directions through one Gate.pump reads 32 KiB at a time and hands every chunk to the Gate before anything reaches the far side:
pump defers a flush on the server direction. Skipping that flush drops the tail of a client’s output, and the user reads it as a truncated result rather than as a masking bug.
Source: hoopinspect/proxy/proxy.go
Inside the Gate
Four steps run per chunk, and the order carries weight. Auditing before the forward costs a write on the hot path. It buys you the property that a crash between the two cannot lose the record of the statement that caused it. Reverse the order and you lose the one row an incident review needs most. A clean payload aliases the input rather than copying it, so a statement nothing touched allocates nothing. Source:hoopinspect/gate/gate.go
Inside the Inspector
A codec registers a factory, not an instance. Two connections sharing one stateful codec would corrupt each other’s reassembly buffer, and one tenant’s SQL would surface in another tenant’s audit trail.splitSimpleQuery splits on semicolons, because SELECT 1; DROP TABLE users arrives as one Q message. A decoder that classified by leading verb would report a harmless select and wave the DROP through.
ClassifySQL strips comments and string literals before it looks for a verb, so SELECT 'DROP TABLE customers' classifies as select. That is why you prefer type: operation over deny_words_list: the word list denies the harmless one.
Source: hoopinspect/inspect.go, hoopinspect/codec/postgres/
Protocols
The Postgres codec is stateful because one
RowDescription describes every DataRow after it, and those land in different TCP reads.
Policy evaluation
Two evaluators compose, local rules first, so a statement the local set already forbids never costs a network round trip.pii rule type dispatches at the rule-set level rather than per rule, because the detector belongs to the rule set and not to any single rule.
Source: hoopinspect/policy/
Masking, and why Postgres needed a second mechanism
Masking runs on responses only. The gate picks a mechanism per protocol by asking the codec, not by consulting a list of protocol names:Content-Length. Leave that header stale and the client reads the old count, stops mid-document, and you get a bug report about a corrupt upstream.
Postgres length-prefixes every row and every column. Substituting ada@example.com (15 bytes) with [REDACTED:EMAIL_ADDRESS] (24 bytes) desynchronizes psql on the next message, and the user sees “lost synchronization with server”. The Postgres codec rebuilds the DataRow frames around the masked values instead.
A codec offering neither mechanism 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.
Source: hoopinspect/codec/postgres/rewrite.go, hoopinspect/gate/contentlength.go
Where PII detection plugs in
The core library ships zero dependencies. A detection engine worth having carries recognizers for dozens of national identifier formats, so it lives behind two interfaces the core already declares.pii policy rule denies what goes in. alcatraz supplies 45 entity types across 12 countries, 25 of them checksum-verified.
It also carries three secret recognizers, which you name in config like any other entity: AWS_ACCESS_KEY, JWT (decodes the header rather than matching its shape) and PRIVATE_KEY.
There are no build tags. The config file decides every capability, so an operator turning on PII detection does not also have to swap the binary.
Source: hoopinspect/pii/alcatraz/
Audit: six kinds, one write path, three sinks
session_start fires even for a connection that issues nothing, so an abandoned session leaves a trace. Without it, a client that connects and disappears is invisible.
Where the events go
The JSONL sink goes first, and the multi-sink attempts every sink regardless of what an earlier one returned, so the durable record survives a failure in the in-memory ring or the query store. Both in-memory sinks drop their oldest entry when full and report the drop count, so a reader can tell a partial window from a complete one. The JSONL stream stays the record of truth. Source:hoopinspect/audit/, hoopinspect/store/
Reading it back
principal, connection, protocol, since, until, denied, open, q for substring search, plus limit and cursor for paging. /api/events also takes session_id and a repeatable kind.
Deploying it
Envoy over a unix socket
A TCP listener on 15432 is reachable by anything that can route to the pod. A NetworkPolicy narrows that; it does not remove it. With a socket there is no port, so reachability stops being a network question and becomes a filesystem one, which is the argument for a sidecar sharing a namespace with one workload. The cost is the setup below. Where it does not fit, a TCP port carries the same traffic. Three pieces have to agree: the lane, the Envoy cluster, and the directory both mount.config.yaml
pipe: and must be STATIC, since a filesystem path resolves to nothing:
envoy.yaml
docker-compose.yml
umask 0002 on the relay are what make the sockets group-writable, which is the permission Envoy needs to connect.
Keep the admin listener on TCP. It serves /healthz to a healthcheck and /stats to a scraper, and moving it to a socket means exec-ing into the container to read either.
Proving the port is gone
Ask the relay’s own namespace what it bound::::15432 and :::18080. From a peer, nc -z -w2 hoop-inspect 15432 reports closed.
Restarting after an unclean exit
Go unlinks the socket when the listener closes, so SIGTERM leaves nothing behind. A SIGKILL, an OOM kill ordocker kill skips that and the file outlives the process. The relay reclaims it at startup by dialing the path: a socket nothing answers on gets unlinked with a warning, and one that answers is left alone while the bind fails, naming the conflict. Two relays sharing a socket would split a client’s connections between them at random.
Envoy over a TCP port
Sockets need both processes to mount one directory and agree on uids. That is cheap in a pod spec and awkward where the relay and its peer sit on different hosts. Dropnetwork from the lane, give listen a host:port, and point a STRICT_DNS cluster at it:
config.yaml
envoy.yaml
0.0.0.0 bind is reachable by anything that can route to the host, and the relay authenticates nobody: it assumes whatever reaches it already passed identity.
As a sidecar container
The relay ships as a small static binary that reads one file. A Dockerfile is in the repository; the process needs no privileges, so run it as a non-root user.docker-compose.yml
On Kubernetes
The same shape, with anemptyDir in place of the named volume:
fsGroup on the pod securityContext replaces the chown step: the kubelet applies it to the emptyDir before any container starts. Set it to Envoy’s gid and both sides can use the directory.
Mount the config as a ConfigMap and set HOOP_INSPECT_CONFIG instead of passing a flag.
Identity
Every session recordsprincipal: anonymous unless a deployment fills it. The plumbing runs end to end: the session carries a subject, and the proxy exposes a seam a deployment fills from a verified JWT, an mTLS peer cert or a credential token. A listener names its identity_header, and the current implementation contributes only the peer address.
Until that function is written, a Rego policy keyed on input.context.user reads anonymous from every lane.
Known limits
Read these before writing a policy against the relay.- Two codecs ship: postgres and http. Adding one means a new
codec/<name>package and nothing else. Tablesis best effort. A lexer, not a SQL grammar, which is the same ceiling Envoy’s own docs acknowledge forpostgres_proxy. Empty means “could not determine” and never “touches nothing”. Setrequire_table_match: trueon rules protecting something critical and accept the false positives.- A response batch can be truncated. The Postgres codec stops decoding columns past 1000 rows in one result set, to keep the relay’s memory out of a query’s hands. It keeps counting and marks the batch truncated. A policy must read that as inconclusive, never as proof a value is absent.
- A response statement carries no verb. For database codecs a server-direction statement reports
unknown, because the operation belongs to the request the audit trail already recorded. Key a response-side SQL rule on the result, not on the operation. - PII detection is neither sound nor complete. A checksum-verified identifier holds up. Everything else is a pattern. Detecting a name column needs NER, which this does not wire, and a caller can split a value across two responses. Masking raises the cost of accidental exposure without replacing “do not grant access to that table”.
- HTTP/1.x only for stream decoding. HTTP/2 and HTTP/3 framing belongs to whatever terminated the connection.
- Plaintext at the relay. A client negotiating TLS end to end past the relay leaves nothing to parse. Something in front terminates it: an HTTPS listener covers HTTP, and
postgres_proxywith astarttlssocket covers Postgres. The upstream leg may be TLS, which the relay originates itself. - Statements are not transactions. Each one gets its own verdict, and no cross-statement session state exists.
- No SSH lane. No SSH codec ships. Envoy ships no SSH filter at any fidelity either, so every service reached over SSH sits unpoliced by the Envoy and OPA layer.
Reference stack
The repository ships a compose stack that runs every component on this page end to end.Next
Get Started
Configure Envoy, run the relay, and watch a denial land in psql.
Config File
Every section, every rule type, and what startup refuses.