~/log/llm-query-database-pg-sentinel
Letting an LLM query your database without losing sleep
AI 4 min read
I have two demos I could show you. In the first, an LLM answers business questions by querying a Postgres database directly — no dashboards, no pre-built endpoints, just plain English in and a correct answer out. In the second demo, the same setup goes wrong: the model either modifies data it should never have touched, or executes a command smuggled in through the data it just read back. I only ever recorded the first one.
That gap between the two demos is the interesting part. It’s not a prompt-engineering problem — no system prompt reliably stops a model from doing something it’s capable of doing. It’s a threat-modeling problem: the only fix that holds is making the bad outcome structurally impossible, enforced below the level of the conversation entirely.
The threat model
Before writing any defense, it’s worth naming who you’re defending against and what you’re protecting.
Actors:
- a well-meaning but wrong model — it hallucinates a table, misreads intent, or “helpfully” tries to fix data it thinks is broken,
- a prompt-injected model — reading hostile instructions embedded in the data it just fetched, not from the user at all,
- a curious user — poking at the edges of what the system will let them do, with no malice required.
Assets:
- Integrity — no
INSERT,UPDATE,DELETE, DDL, or any sneakier route to a write, - Confidentiality — no
pg_read_file, nodblink, no reaching outside the data the caller is meant to see, - Availability — no query that quietly eats all your CPU or holds a table lock for ten minutes.
Explicit non-goal: row-level access control. That’s Postgres’s job — roles and RLS policies already solve it well, and re-implementing it in an application layer is how you end up with two sources of truth that drift apart.
Layer 1: a real parser, not a regex
The obvious first instinct is to blacklist keywords — reject any query containing DELETE, DROP, UPDATE. It doesn’t hold up. pg-sentinel instead parses every query with pglast, which builds the same syntax tree Postgres’s own parser would build, and then checks the tree — not the string.
That catches the structural attacks a keyword filter misses:
- a
WITHclause hiding a write inside a CTE, EXPLAIN ANALYZE, which executes the query rather than merely planning it,- a dangerous function called through schema-qualification to dodge a name-based filter,
- multiple statements smuggled in after a semicolon.
The analyzer requires exactly one top-level SELECT and checks every function call against a denylist. If the tree doesn’t look like a single, harmless read, it never reaches the database.
Layer 2: assume layer 1 has bugs
Parsers have bugs. Denylists miss things. So the second layer doesn’t trust the first one at all: every query runs inside a READ ONLY transaction that is always rolled back, no matter what happened inside it. Even if the analyzer is wrong or gets bypassed, Postgres itself refuses to persist a write — the enforcement point isn’t the application, it’s the database engine. On top of that sit a statement timeout and a row cap via a server-side cursor, so a legitimate but enormous query can’t become an availability problem.
Layer 3: a role that physically can’t write
The last layer doesn’t depend on application logic at all. The server connects as sentinel_ro, a Postgres role granted SELECT and nothing else. For a write to actually happen, the parser would have to miss it, the rolled-back transaction would have to somehow persist, and the role would need privileges it doesn’t have — all three, at once.
Adversarial testing drove the design, not the other way around
The analyzer rules didn’t get bolted on after the fact — they came out of a 50-plus case adversarial test suite written alongside the design, where every attempted attack that got through became a new rule. That ordering matters: it’s the difference between defenses that cover the attacks you thought of, and defenses that cover the attacks you tried and failed at.
What this doesn’t do
pg-sentinel doesn’t implement row-level access control — that stays with Postgres’s roles and RLS, deliberately. There’s also an optional write mode for preview/confirm workflows, but it’s kept entirely separate from the read path rather than bolted onto it, so the safe path never inherits the risk of the write path.
The point
The goal was never to convince a model to behave. It was to make misbehavior technically infeasible regardless of what the model decides to do — moving the problem from behavioral (“please don’t”) to architectural (“you structurally can’t”). Code and a demo with a pre-configured Postgres instance are up on GitHub.
Full write-up: Letting an LLM Query Your Database Without Losing Sleep
~/related