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. The Sidecar 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.
The HTTP codec captures nothing by default: no bodies, no headers. Everything captured reaches the policy engine, the audit trail and, where an analyzer is configured, a third party, so a lane opts in per listener with an
http: block. See Risk Analysis.Where Envoy ends and the Sidecar begins
Envoy is not blind here, and an argument that says otherwise loses a technical review. The Sidecar covers what remains.
One process, one lane per Envoy cluster
The Sidecar 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 Sidecar 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 Sidecar 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 rather than 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.SELECT 1; DROP TABLE users arrives as one Q message. A decoder classifying by the leading verb would report a harmless select and forward the DROP. The scanner owns the split, so a semicolon inside a string literal, a dollar-quoted body or a nested comment is never a separator.
AnalyzeSQL scans once and tracks nesting on a labelled region stack: every level knows whether it is a CTE, a subquery or a plain parenthesis. The previous classifier carried a single paren-depth integer, which is why WITH d AS (DELETE FROM customers RETURNING *) SELECT count(*) FROM d read as a select against [customers d]. It now reports delete against customers as a write.
Three fields come out of it, and a rule can key on any of them:
Comments and string literals never reach the token stream, so
SELECT 'DROP TABLE customers' is a select and a deny_words_list rule on DROP denies that harmless query. Prefer type: operation, and see Policy Rules for what each rule type reads.
A statement whose body the scanner cannot reach reports Operation: unknown and sets Metadata["sql.incomplete"] to the reason: CALL purge() gives “stored procedure; body is in the catalog”, EXECUTE p gives “prepared statement; the text was supplied elsewhere”, DO $$…$$ gives “anonymous code block; body is interpreted at runtime”. That pair is the fail-closed signal. Name unknown in a rule and the Sidecar refuses what nobody can read.
Checked against PostgreSQL’s own grammar
lexer/conformance is a separate module that parses every fixture with PostgreSQL 17.5 through wasilibs/go-pgquery and compares write-sets. It runs under wazero with CGO_ENABLED=0, so it needs no C toolchain, and nothing the root builds imports it: hoopinspect still has zero requires and no go.sum.
The suite asserts one thing: the scanner’s write-set equals PostgreSQL’s, or the scanner reported
Complete=false. A confident, different answer fails the build.
DO, CALL/EXECUTE and a function call inside a SELECT list are a permanent ceiling that no parser clears, PostgreSQL’s own included, because the body sits in the catalog or was supplied on another round trip. The first two set Complete=false; the third does not, because flagging every SELECT count(*) would make the flag meaningless. So operations: [unknown, other] is a standing posture on a lane that must fail closed. MSSQL runs this scanner permanently: no credible Go T-SQL parser exists.hoopinspect/lexer/, hoopinspect/sqlmeta.go, hoopinspect/codec/postgres/
Protocols
Both database codecs are stateful: one Postgres
RowDescription describes every DataRow after it, one TDS COLMETADATA describes every ROW, and those land in different TCP reads.
Policy evaluation
Evaluators compose in ascending order of cost, so a statement an earlier one already forbids never pays for a later one.DELETE a local operation rule already refuses never reaches a model. That ordering is load-bearing.
A rule carrying action: defer reports its match as a finding and evaluation continues. The finding travels in-process on the evaluation context and reaches the decide-phase OPA call as input.findings, keyed by the rule’s type. A producer that ran and could not answer still appears, carrying a status, because “no answer” is the case a policy most needs to see and the case an absent key hides.
The gate runs before the producers and answers whether they are worth running, which is how the cost control survives moving a determination into Rego. Policy Rules carries the finding shape, the status vocabulary and the Rego that reads it.
An
ai_analysis rule is not evaluated by the local rules engine. It needs a provider, a deadline and a cache, none of which belong to a text matcher, so the sidecar lifts those rules out of the set at startup and builds one evaluator per rule, appended after OPA. Two consequences worth knowing:
- Ordering between an
ai_analysisrule and the local rules in the same list does not matter. Local rules always run first. - A lane can carry several
ai_analysisrules. Each is its own evaluator with its own trigger, actions and prompt; if more than one classifies the same statement, the audit record keeps the highest risk reported and the action that came with it.
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.
MSSQL has the same problem in a different framing, and no Content-Length to correct. Its codec reassembles the TDS token stream across packets, rewrites ROW and NBCROW values, and lays fresh packets over the result. A column type it cannot measure (SQL_VARIANT, XML, UDT) stops the rewriting for that connection rather than guessing a length; statements and policy carry on.
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/codec/mssql/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 is a filesystem question, which is the argument for a sidecar sharing a namespace with one workload. The cost is the setup below. A TCP port carries the same traffic where that setup does not fit. 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 Sidecar 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 Sidecar’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 Sidecar 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 Sidecar 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 Sidecar authenticates nobody: it assumes whatever reaches it already passed identity.
As a sidecar container
The Sidecar 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_SIDECAR_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.
The session reaches Rego as input.context, carrying principal, session_id and connection on every statement, plus subject, email, groups, peer_addr, upstream and correlation_id where the identity supplies them. Until that seam is filled, a policy keyed on input.context.principal reads anonymous from every lane.
A sidecar lane used to send an empty
input.context. The gate looked for a bare *policy.OPAClient to stamp the session onto, and a lane’s policy is a policy.Chain, so the assertion never matched and the facts never left the process. The gate now seeds the evaluation context instead, which every evaluator in the chain reads, including both calls on a two-phase lane.Known limits
Read these before writing a policy against the Sidecar.- Three codecs ship: postgres, mssql and http. Adding one means a new
codec/<name>package and nothing else. - Relations come from a scanner, not a SQL grammar. One pass over a labelled region stack, reporting each relation as a read or a write. A statement it cannot finish comes back with no relations and an
unknownoperation carrying the reason inmetadata["sql.incomplete"], so “could not determine” never reads as “touches nothing”. Setrequire_table_match: trueon rules protecting something critical and accept the false positives.lexer/conformanceholds the scanner to zero wrong answers against PostgreSQL 17.5’s grammar. - A response batch can be truncated. The Postgres codec stops decoding columns past 1000 rows in one result set, to keep the Sidecar’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. - MSSQL statement extraction covers
sp_executesql.sp_prepare,sp_prepexecand the cursor family yield no statement, which is how JDBC and .NET send prepared statements.EXEC('DELETE ...')reportsunknownwith the reason “stored procedure; body is in the catalog”, so a rule namingunknownrefuses it and one namingcallno longer matches. - 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 Sidecar, with one exception. A client negotiating TLS end to end past the Sidecar leaves nothing to parse. Something in front terminates it: an HTTPS listener covers HTTP,
postgres_proxywith astarttlssocket covers Postgres, and a plainDownstreamTlsContextcovers TDS 8.0. TDS 7.x is the exception, because nothing can terminate it: PRELOGIN’sENCRYPT_OFFencrypts the first LOGIN7 packet and leaves every statement in the clear, so the Sidecar walks that region by TLS record framing and reads the session after it. See Kerberos and SQL Server. The upstream leg may be TLS, which the Sidecar originates itself, except on MSSQL where SQL Server on Linux accepts no handshake the Sidecar can start. - A session encrypted end to end is refused, not forwarded.
ENCRYPT_ON, or a TDS 8.0 client reaching the Sidecar with nothing terminating in front, leaves no statement that can be classified, masked or audited. Both reportrule: stream-unsafeand name the cause. Such a session used to connect and run uninspected, so check what your driver sets forEncryptbefore putting an existing MSSQL lane behind this: ODBC Driver 18 and SqlClient 5+ default toMandatory, go-mssqldb toENCRYPT_OFF. - 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
Policy Rules
Every rule type, deferring a match to Rego, and the findings a policy reads.
Get Started
Configure Envoy, run the Sidecar, and watch a denial land in psql.
Config File
Every section, every rule type, and what startup refuses.