{"slug": "we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is", "title": "We let an AI write SQL against customer databases. Here is every guardrail, and why prompting is not one.", "summary": "An engineer at ChatterMate detailed the guardrails built into an AI agent that queries customer databases, emphasizing that all security checks are enforced on a parsed AST rather than through prompts. The system validates SQL with sqlglot, restricts statements to single SELECTs, blocks dangerous node types, and enforces table allowlists that account for CTEs and subqueries. The code is open-sourced under Apache-2.0.", "body_md": "A customer writes in: \"my last three orders are missing.\" Someone has to open a database and look.\n\nWe built an AI agent that does that part. It reads the ticket, forms a hypothesis, queries the customer's own systems, and writes up what it found with the queries attached as evidence. Useful. Also, on its face, a terrible idea — you have an LLM reading untrusted text from strangers on the internet, and you are handing it a database connection.\n\nWe shipped it anyway. The interesting work was not the agent. It was the roughly 400 lines standing between the agent and the data, and the thing we got wrong twice before getting it right.\n\nAll of this is Apache-2.0 and sitting in [our repo](https://github.com/ChatterMate/chattermate.chat), so you can read the real thing rather than take our word for it. The two files are `backend/app/services/sql_guardrails.py`\n\nand `backend/app/services/db_connector_service.py`\n\n.\n\nStart here, because everything else follows from it.\n\nYou cannot write \"only generate SELECT statements\" in a system prompt and call that a control. You can write it — we do — but it is a hint about intent, not a guarantee about behaviour. The model reads customer messages. Customer messages can contain instructions. Somewhere between those two facts is every prompt-injection writeup of the last three years.\n\nSo the rule we settled on: **every guarantee is enforced outside the model, on a parsed AST.** Not on the string. Not by asking nicely. If a property matters, it gets checked by code that the model cannot talk to.\n\nThat sounds obvious written down. It is also the part most \"AI + your database\" demos skip, because the demo works fine when nobody is attacking it.\n\nThe first thing we do with model-generated SQL is throw away the string.\n\n```\nstatements = sqlglot.parse(raw_sql, read=dialect)\nif len(statements) != 1:\n    return SqlValidationResult(ok=False, reason=\"Exactly one SQL statement is allowed\")\nstatement = statements[0]\n\nif not isinstance(statement, exp.Select):\n    return SqlValidationResult(ok=False, reason=\"Only plain SELECT statements are allowed\")\n```\n\nThen a denylist of node types that must not appear anywhere in the tree: `Insert`\n\n, `Update`\n\n, `Delete`\n\n, `Merge`\n\n, `Create`\n\n, `Drop`\n\n, `Alter`\n\n, `TruncateTable`\n\n, `Grant`\n\n, `Command`\n\n, `Transaction`\n\n, `Set`\n\n, `Into`\n\n, `Lock`\n\n.\n\n`Into`\n\nand `Lock`\n\nare on that list for a reason. `SELECT * INTO evil FROM orders`\n\nis a SELECT, and it writes. `SELECT * FROM orders FOR UPDATE`\n\nis a SELECT, and it takes locks. Both parse as `exp.Select`\n\n. Both would have walked straight through a naive `sql.strip().upper().startswith(\"SELECT\")`\n\ncheck.\n\nAt the end, the statement is re-rendered from the AST with `comments=False`\n\n, and that rendering is what executes. Two things fall out of that. Comments never reach the server, which matters more than it sounds — MySQL executes `/*! ... */`\n\n. And whatever runs is canonical SQL derived from a tree we inspected, not the raw string we were handed.\n\nEvery table reference has to be on the connector's allowlist. The obvious version of this check is also the broken one, because table references hide in places people forget to look.\n\n```\nWITH x AS (SELECT * FROM pg_shadow) SELECT * FROM x\n```\n\nThe top-level FROM is `x`\n\n. Looks clean. The real read is inside the CTE body.\n\n```\nSELECT * FROM (SELECT id FROM orders UNION ALL SELECT oid FROM pg_shadow) t\n```\n\nSame trick, different wrapper.\n\n`statement.find_all(exp.Table)`\n\nwalks all of it — CTEs, subqueries, joins, union arms. CTE *names* are exempted, since a reference to `x`\n\nresolves to a CTE we already walked, but only when unqualified. A CTE called `users`\n\ndoes not authorize `secret_schema.users`\n\n. That is a real test case, and it exists because it is exactly the sort of thing you would get wrong.\n\nBare names resolve against default schemas, so an allowlist entry of `public.orders`\n\nalso authorizes `orders`\n\n. But `hidden.orders`\n\nis refused. Same table name, different schema, no.\n\nThis is the part worth the whole article.\n\nSupport data is multi-tenant in the worst way: one `orders`\n\ntable, every customer's rows in it. The agent investigating Ann's missing orders must not be able to read Bob's. And the naive approach — check that the model's WHERE clause contains the right customer filter — cannot be made to work.\n\nThink about what you would have to prove. That no `OR`\n\nwidens the predicate. That no join reintroduces unfiltered rows. That no correlated subquery reaches around it. That no UNION arm adds a branch without it. You are writing a static analyser for arbitrary SQL semantics, and you will lose.\n\nSo we stopped validating the predicate and started rewriting the relation:\n\n``` php\nFROM orders o   ->   FROM (SELECT * FROM orders WHERE customer_email = 'ann@x.com') AS o\n```\n\nEvery scoped base table gets swapped for a pre-filtered subquery. The model's own SQL is then applied on top of that.\n\nNow `OR 1=1`\n\nis not a bypass. It is not even interesting. The outer predicate can say whatever it likes, because the relation it selects from only ever contained one customer's rows:\n\n``` python\ndef test_or_true_cannot_widen_the_scope(self):\n    result = scoped(\"SELECT id FROM orders WHERE 1=1 OR customer_email = 'victim@x.com'\")\n    assert result.ok\n    assert \"FROM (SELECT * FROM orders WHERE customer_email = '%s')\" % OWNER in result.sql\n```\n\nNote that the query is *allowed*. We are not blocking it. We are making it harmless, which is a much better place to be than pattern-matching hostile predicates forever.\n\nThree details that took us longer than they should have:\n\n**It fails closed.** A scoped table with no customer identity on the ticket is refused outright. The tempting behaviour — no scope value, so skip the filter — reads every customer's rows. That is the bug you only find in production, so there is a test pinning it shut for `None`\n\n, `\"\"`\n\nand `\" \"`\n\n.\n\n**The value is a literal node, never string-formatted.** `exp.Literal.string(value)`\n\nescapes on render. Feed it `x' OR '1'='1`\n\nand you get `'x'' OR ''1''=''1'`\n\n— one string, no injection. We are rewriting SQL to defend against injection, so it would be somewhat embarrassing to introduce one while doing it.\n\n**Scoping happens last.** The allowlist and masking checks run against what the model actually wrote. If we rewrote first, those checks would be inspecting our own wrappers.\n\nSome columns should never reach the model at all. Email, phone, whatever your compliance people circled.\n\nThe naive implementation masks by output column name, after the query runs. It survives about ninety seconds of adversarial thinking:\n\n```\nSELECT email AS harmless FROM customers          -- alias\nSELECT substr(email, 1, 3) FROM customers        -- expression\nSELECT id FROM customers WHERE email = 'a@b.c'   -- probe by inference\n```\n\nThe last one never selects the column at all. It just asks yes/no questions until it knows the value.\n\nSo masked columns cannot be *referenced*, anywhere. Not in SELECT, not in WHERE, not inside a function.\n\nThen there is the category we did not think of first, and which is the reason this section exists:\n\n```\nSELECT to_jsonb(t) FROM customers t\nSELECT row_to_json(customers) FROM customers\nSELECT customers FROM customers\nSELECT customers::text FROM customers\nSELECT (c.*)::text FROM customers c\nSELECT array_agg(customers) FROM customers\n```\n\nEvery one of those collapses an entire row into a single value. Name-based masking looks at the output column, sees `to_jsonb`\n\n, finds nothing called `email`\n\n, and passes the whole record through with the email inside it.\n\nIn Postgres a bare table name used as a value *is* the row. `SELECT customers FROM customers`\n\nis legal and returns row tuples. That one genuinely surprised us.\n\nThe fix is two rules on the AST. A star nested inside anything other than a top-level projection is refused. And a bare, unqualified column reference whose name matches a table, alias or CTE in scope is refused, because that is a whole-row value wearing a column's clothes.\n\n`SELECT *`\n\n, `t.*`\n\nand `count(*)`\n\nstay legal. The first two expand to real column names that get redacted by name on the way back; the last is a count, and counts do not leak rows.\n\nEverything so far is one system. One system means one bug away from nothing.\n\nSo the session is independently read-only, at the driver:\n\n```\n# postgres\noptions=\"-c statement_timeout=5000 -c default_transaction_read_only=on\"\n\n# mysql\ncursor.execute(\"SET SESSION TRANSACTION READ ONLY\")\ncursor.execute(\"SET SESSION max_execution_time = 5000\")\n```\n\nIf the AST validator has a hole we have not found, a write still fails at the server. The statement timeout does the same job for the resource-exhaustion cases the function denylist is aimed at — `pg_sleep`\n\n, `benchmark()`\n\n, and the rest of that family.\n\nAnd the executor fetches `max_rows + 1`\n\neven though the validator already forced a LIMIT. Belt and braces, deliberately.\n\nTwo things, stated plainly, because a security post with no open questions is a marketing post.\n\n**The denylist is a denylist.** `BLOCKED_FUNCTIONS`\n\ncovers the Postgres and MySQL functions we know about for sleeping, reading files, opening network connections and poking at server config. Denylists are structurally incomplete. It is defence in depth behind the read-only session, not the thing holding the line, and we would rather have an allowlist here — we have not found a workable one that does not break ordinary queries.\n\n**Row scoping is opt-in, and that is a sharp edge.** `row_scope`\n\nis empty until you set a customer-identifying column per table. An operator who connects a database and skips that step gets an agent that can read every row of every allowlisted table. The docs say to set it. Defaults beat documentation, and this default is the wrong way round. It is the change we would most like to make.\n\nThe whole corpus lives in `backend/tests/services/test_sql_guardrails.py`\n\n— mutations, table smuggling, function abuse, masked-column exfiltration, row-scope bypasses. If you find one it does not cover, that is a genuinely useful issue to open, and we would rather hear it from you than from a customer.\n\nIf you are building an agent that touches real data, the shape of this is reusable even if none of the code is:\n\nNone of this makes an LLM trustworthy. That is the point. It makes trustworthiness unnecessary, which is the only version of this that scales.\n\n*I build ChatterMate, an open-source AI customer support platform with human handoff — Apache-2.0 and self-hostable. Everything above is in the repo at github.com/ChatterMate/chattermate.chat, so go and find the hole I missed.*", "url": "https://wpnews.pro/news/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is", "canonical_source": "https://dev.to/chattermate/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-why-prompting-is-3k9a", "published_at": "2026-09-04 07:21:33+00:00", "updated_at": "2026-09-04 07:53:58.411348+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["ChatterMate", "sqlglot"], "alternates": {"html": "https://wpnews.pro/news/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is", "markdown": "https://wpnews.pro/news/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is.md", "text": "https://wpnews.pro/news/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is.txt", "jsonld": "https://wpnews.pro/news/we-let-an-ai-write-sql-against-customer-databases-here-is-every-guardrail-and-is.jsonld"}}