Letting people ask their database questions in plain English is appealing, and dangerous if done carelessly. A language model can be persuaded, confused or simply wrong. So the safety of the system cannot depend on the model behaving. Here are the layers we use in our own prototype, and why each one exists.
Layer 1: tell the model the rules
The prompt describes the allowed schema and the constraints: one SELECT statement, only allowed tables and columns, always include a LIMIT, and refuse anything else. It also helps to show the model only an allow-listed slice of the schema, so it cannot even name tables it should not see. This layer is useful and it is the weakest one. Never rely on it alone.
Layer 2: validate the SQL itself
Regular expressions are easy to bypass with comments, whitespace or encoding tricks. Parse the SQL into a syntax tree instead, and reject on structure: more than one statement, any mutating node anywhere in the tree, row-locking clauses, bare SELECT *, and any table or column outside the allow-list.
The 'anywhere in the tree' part matters. This query parses as a SELECT at the top level, yet contains a delete inside a common table expression:
WITH x AS (DELETE FROM customers RETURNING *) SELECT * FROM x;
A check that only inspects the first keyword would let it through. Walking the whole tree catches it.
Layer 3: let the database refuse
Run the query as a database role that has SELECT-only permission on exactly the tables you exposed, with a statement timeout and no access to your audit log. Now even if the first two layers were bypassed, Postgres itself refuses to write. This is what turns a set of app-level checks into real defence in depth.
Small controls that add up
- Cap the number of returned rows.
- Set a short statement timeout so expensive queries are cancelled.
- Allow one retry on failure, not a loop, so runaway cost is structurally impossible.
- Log every attempt, including refusals, through a separate connection the query role cannot read.
- Write the explanation from the actual result rows, so the model cannot invent a number.
Test the guardrails, not just the happy path
In our prototype we probe each layer separately, including sending hostile SQL directly to the read-only connection to prove it is a real backstop and not just redundant. A guardrail you have not attacked is a guess.
If you are considering natural-language access to your own data, this is the kind of design we can review with you.