A text-to-SQL agent is one of the easiest AI features to demo and one of the more awkward ones to get through a security review. A non-technical user asks a question in English, a model writes SQL, the warehouse runs it, and a table comes back. The demo takes an afternoon. The review tends to stall on a harder question: what actually stops the agent returning data the person asking is not entitled to see?
In the reviews I sit in, the first answer is usually a sentence in the system prompt. Never return customer email addresses. Only query the orders table. Do not run anything expensive. Those sentences are worth writing, and OWASP’s own first mitigation for prompt injection is to constrain model behaviour in exactly that way, with specific instructions about the model’s role and limits (OWASP, 2025). My argument is not that the sentences are useless. It is that they are the wrong artefact to point at when somebody asks you to demonstrate that the rule holds, because an instruction sitting in the context window is subject to the same forces as every other token in that window.
Willison, who coined the term prompt injection by analogy with SQL injection, puts the mechanism plainly: LLMs follow instructions in content, and they cannot reliably distinguish the importance of instructions based on where those instructions came from (Willison, 2025). OWASP ranks prompt injection first in its Top 10 for LLM applications and is unusually direct about the outlook, noting that given the stochastic influence at the heart of the way models work, it is unclear whether any fool-proof method of prevention exists (OWASP, 2025).
In a text-to-SQL agent the injection surface is wider than the user’s question. Tool results reach the context, as do column descriptions pulled from the information schema, and a returned row with instructions written into a free-text field. Any of those can carry the argument that talks the model past the rule you wrote.
My working assumption, and I would not claim it as more than an assumption, is that a rule an LLM enforces is a rule an LLM can be argued out of. If you need to show an auditor that a rule holds, the rule has to live somewhere you can test it and be proven wrong.
OWASP’s own mitigation list says as much further down, recommending deterministic code to validate adherence to expected output, and handling privileged functions in code rather than handing them to the model (OWASP, 2025). That is the design this post is about.
What a second model buys you, and what it does not
The reflex fix, once a team accepts that the system prompt is porous, is to add a judge: a smaller LLM that looks at the generated SQL and rules on whether it is safe.
I think that makes a reasonable detective control and a poor floor. It costs tokens on every query and adds a network round trip to a path users experience as latency, which are the boring objections. The one I care about is that a judge is a probabilistic classifier, and the figure these systems tend to advertise sits somewhere around 95 percent. Willison’s line on the guardrail vendor category is that in web application security 95 percent is a failing grade (Willison, 2025), and I would apply the same standard here. A control that is right most of the time can sit above your floor, and I would not build a floor out of one.
Where a judge does earn its place is on the classes a parser structurally cannot see, and I will get to those below: re-identification through innocuous columns, PII addressed by a string literal inside a JSON payload, aggregations that are each individually fine and jointly identifying. Those are semantic questions, and a grammar has no opinion on them. My preference is to run a judge above a deterministic layer where the budget allows, rather than in place of one.
Move the rule to where the SQL is
The SQL an agent produces is a string. Before it reaches the warehouse it is inert, fully inspectable, and, unlike natural language, has a grammar. That is the moment where a rule can be enforced by something that does not take arguments.
sql-guard is our attempt at that control. It is a policy engine that sits between the model’s output and the warehouse client: it parses the SQL, runs an ordered list of rules against the syntax tree, and returns allow, confirm or deny. There is no LLM in the guard path. It is Apache-2.0, pure Python, and depends on sqlglot and nothing else. Install agent-sql-guard, import sql_guard, Python 3.11 or later (Sakura Sky Engineering, 2026).
from sql_guard import PiiDenylist, SqlGuard, SqlGuardConfig
guard = SqlGuard(SqlGuardConfig.from_settings(
pii_denylist=PiiDenylist.from_mapping({
"columns": ["email", "phone_number", "ssn"],
"substrings": ["address"],
}),
allowed_tables=["my-project.analytics.orders"],
dialect="bigquery",
))
decision = guard.evaluate_static(
"SELECT customer_id, COUNT(*) FROM `my-project.analytics.orders` GROUP BY 1"
)
if decision.denied:
return decision.reasonListing 1. The policy is configuration. The decision comes back before the warehouse client is called.
Six rules ship by default. Single SELECT only, so DML, DDL and multi-statement payloads are refused even when buried in a subquery. A PII column denylist. No SELECT * in any scope. Nothing whose columns the guard cannot enumerate. A table allowlist of fully-qualified names. And a cost cap with three thresholds: auto-execute below $0.10, ask for confirmation between, refuse above a $20.00 hard cap or a 10 GiB bytes-billed ceiling, all of them defaults you can move.
Parsing rather than pattern-matching is the load-bearing choice. Regex over SQL is famously brittle, and most of the bypasses described below would have been trivial against a regex while still being reachable against an AST walk that was not careful enough. sqlglot handles the dialect surface (Mao, 2026). BigQuery is the default and the one under heaviest real use. Snowflake, Postgres, Trino, DuckDB, ClickHouse and MySQL have test coverage, and Presto shares Trino’s parser. The remainder of sqlglot’s thirty-plus dialects should work without being battle-worn.
One caveat belongs here rather than in the limitations section at the end, because it decides whether any of this is a boundary at all. A library the caller has to remember to call is a convention. The host process’s warehouse identity is the same whether the guard ran or not, so if another code path in the same process can reach the client directly, the guard is closer to a lint than a control. Putting the credential behind the guarded client, in a separate service account or a proxy the agent process cannot reach around, is what turns the convention into enforcement.
A parser will not be talked out of a rule. It can still be wrong about what the rule covers, and most of the rest of this post is eight examples of exactly that.
Eight ways the PII denylist was bypassed
v0.2.0 exists because an internal adversarial review of a deployed agent found two ways to get denylisted columns past the guard. Fixing those two surfaced six more. The release closes eight, each confirmed against 0.1.1 with a reproducing query before anyone touched a fix, each with a regression test that fails on the old code (Sakura Sky Engineering, 2026). The changelog itemises the work across ten entries, because two of the classes needed separate fixes.
Alias laundering. Rename a denied column inside a common table expression (CTE), the named subquery a WITH clause introduces, then project the alias from the outer query. Each CTE gets its own scope, and the rule called a helper that only read the outermost projection list, so the outer select named city and looked clean.
WITH c AS (SELECT billing_city AS city FROM `p.d.orders`)
SELECT city FROM cListing 2. The CTE scope still names the denied column. Reading only the outer projection sees nothing wrong.
Derived tables, UNION ALL arms and multi-hop alias chains through two CTEs all worked the same way.
Inner-scope stars. The star rule also ran against the outermost select only, so a SELECT * inside a CTE body or derived table went through untouched.
Stars inside a wrapping construct. A separate bug with the same effect. The check inspected the projection’s root node, which meant OBJECT_CONSTRUCT(*) in Snowflake, COLUMNS(*) in DuckDB, * APPLY(f) in ClickHouse and ROW(c.*) in Trino all passed. It is now a deep walk, with COUNT(*) as the explicit carve-out.
Qualified t.*. In sqlglot a qualified star parses as a Column node wrapping a Star, not as a bare Star. An isinstance(projection, exp.Star) check walks straight past it, at the top level as well as anywhere else.
ClickHouse COLUMNS('regex'). Expands to an arbitrary set of columns and parses with no Star node anywhere in the tree to match on.
NATURAL JOIN. Joins on whichever columns the two tables happen to share. Without schema introspection the guard cannot rule out a denied column among them.
Column names that never become a Column node. sqlglot parses several positions as a bare Identifier, so a sweep collecting Column nodes missed them entirely: JOIN ... USING (email), column aliases of the AS g(email) form, and STRUCT('x' AS email) field names. The USING case was a working single-query value oracle.
Aggregates as a blanket exemption. Every aggregate function counted as PII-neutralising, so MAX(email), MIN(email), ARRAY_AGG(email), STRING_AGG(email) and ANY_VALUE(email) all returned real values. Only aggregates that reduce to a derived statistic qualify now.
Whole-row alias references. This one was not in the original report. It was found while fixing the others, and it was the most severe of the eight.
SELECT c FROM `p.d.orders` AS cListing 3. A bare table alias in a value position. In BigQuery this returns every column of every row as a struct.
That parses as an ordinary column named c. The denylist had no denied name to match against, the star check found no star, and the guard auto-executed it. Strictly more powerful than the SELECT * it had been blocking since day one, and syntactically indistinguishable from selecting a column that happens to be called c. The fix also covers TO_JSON_STRING(c), ARRAY_AGG(c), STRUCT(c), the unaliased SELECT tbl FROM tbl form, CTE and derived-table names, and VALUES and PIVOT aliases.
Getting that rule to be usable took more care than getting it to be strict. Denying on a bare name collision would have broken WITH revenue AS (SELECT ..., SUM(x) AS revenue ...) SELECT revenue FROM revenue, which is a mainstream idiom, so the rule resolves the ambiguity from the AST instead: a table contributes only the name it is addressable by, and a CTE publishes its own output names.
The bypasses returned the success state
Most of the eight did not return deny. They returned confirm, and confirm is what static evaluation returns when no rule fires at all.
for rule in self._rules:
decision = rule.evaluate(ctx)
if decision is not None:
return decision
return GuardDecision(
outcome=GuardOutcome.CONFIRM,
reason="Static checks passed; awaiting cost evaluation.",
referenced_tables=tuple(sorted(tables)),
)Listing 4. The tail of evaluate_static. This is the pass state, and it is what a query with nothing wrong with it gets back.
So there was no near-miss and nothing to instrument. Eight queries reached denied columns, and the guard reported that static checks had passed, in the same words it uses for a query that is entirely fine. The whole-row alias went on through the cost gate and executed.
One thing worth knowing if you are building telemetry on this: GuardOutcome is shared across the two evaluation phases and carries a different meaning in each. In evaluate_static, confirm is the pass. In evaluate_cost it means ask the user before running this, and sits between the auto threshold and the hard cap.
A guardrail that fails closed tends to generate a support ticket and get fixed that afternoon. One that fails open often generates very little, which leaves going and looking as the main way anybody finds out. That asymmetry is my argument for treating adversarial review of this class of component as routine work, and it is why every fixed bypass now carries a test that fails against the version before it. A good share of the tests across those five files exist to keep the eight closed.
The fix broke our own demo
Tightening a guardrail means queries that used to work now do not, and 0.2.0 contains breaking behaviour changes on purpose.
The clearest casualty was the identity-resolution query bundled with the project as a worked example. It normalises email and mobile inside a CTE and projects only COUNTIF aggregates, an ordinary pattern in analytics that looks careful. It is now denied in both PII modes: the CTE scope projects the denied columns, and COUNTIF(email_norm = 'target') is itself a value oracle.
A WHERE clause is an oracle
sql-guard has two PII modes. "reference", the default, denies any mention of a denied column anywhere in the query. "project" denies only projections, checked across every scope.
Projection-only checking is the intuitive design and it does not hold, because a denied column in a predicate never appears in the output while still answering a question about itself.
SELECT COUNT(*) FROM `p.d.orders` WHERE billing_city = 'Columbus'Listing 5. Passes a projection-only guard. Returns zero or non-zero, which is one bit.
Run it again with LIKE 'a%', then > 'm', and you are doing binary search against a value you were never allowed to read. Depending on the cardinality, a handful of queries recovers it. GROUP BY, HAVING and ORDER BY leak the same way. The row count is the channel, and a rule that only inspects projections cannot see it.
Hence the strict default. If a deployment genuinely needs predicate access to a denied column, pii_mode="project" is there and the trade-off is stated in the docs. Narrowing the denylist, or pointing the agent at a pre-masked view the denylist does not cover, is usually the better move.
One nuance is easy to get backwards, and a careful reader of the changelog did: aggregation is not a safe harbour. COUNT, SUM, AVG and their relatives are treated as PII-neutralising only under pii_mode="project". Under the default mode no aggregate is exempt, because the whole point of the default is that the agent must not learn the values at all, and COUNT(*) ... WHERE email = ... learns them.
Limits of a parse-level SQL guard
These limits are structural. I would sooner publish them than have somebody discover them.
PII inside JSON, VARIANT or STRUCT payloads is not covered. JSON_VALUE(payload, '$.email') names only payload; the field name is a string literal the engine resolves at runtime. Denylist the containing column.
Two whole-row reads remain open by design. Selecting a STRUCT column whole, and an UNNEST alias over an array of structs, both return every field without naming one. Neither is distinguishable at parse time from the scalar-array form that is idiomatic and has to stay allowed. Same remedy: denylist the containing column.
Re-identification through non-PII columns is out of scope. If uid maps one-to-one to a person, blocking email does not stop correlation against an outside dataset.
Side channels remain. Row counts, dry-run byte figures and error messages all carry bits about denied values even when every direct reference is refused. The oracle above is the version we closed, and the family is larger than the fix.
There is no schema introspection. If you say a table is allowed, the guard takes your word for it. That constraint is the reason SELECT * is rejected everywhere instead of reasoned about.
The cost cap bounds a query, not a spend. evaluate_cost is per-call by construction. An agent in a retry loop issuing two thousand queries at nine cents each trips nothing. Cumulative exposure needs warehouse-side maximum-bytes-billed and a budget alert.
It is not an authorisation layer. Identity, IAM and row-level security sit outside it. It can approve a query that a correctly configured warehouse would have refused on identity grounds.
It is a boundary only where it is the only path to the credentials. Covered above, and in my experience the most common way this control gets downgraded to a suggestion.
The changelog also carries two known issues we have not fixed and one inconsistency: a top-level EXCEPT DISTINCT or INTERSECT is currently rejected as a non-SELECT, which fails closed and is an availability bug rather than a security one; allowlist breaches are under-reported in telemetry built on decision.reason because that rule runs last; and two spellings of hashed PII are handled inconsistently, in the direction of denial. Anyone evaluating this would do well to read that section alongside the README.
Defence in depth, and what the depth sits on
Warehouse-side column-level and row-level security are the durable answer to most of this. Policy tags on sensitive columns, masking policies, row access policies scoped to the calling principal: enforcement that lives with the data, applies to every client, and does not care whether the query came from an agent, a BI tool or somebody’s notebook. Where a team can get there, I would push them to.
The gap sql-guard fills is between deciding that and having it. Warehouse-side controls need coordinated schema work, a data classification exercise that is usually half-finished, and sign-off from teams who own tables you do not. In the programmes I have watched, that runs to quarters rather than weeks. A denylist and an allowlist in a config file is an afternoon, it gives you a cost cap that column security does not, and it keeps working as a second layer once the warehouse work lands.
Message-boundary guardrails such as NeMo Guardrails or LangChain’s belong in the same picture. They watch intent in the conversation, this watches the query at execution, and the failure modes look different enough to me that running both is usually worth the tokens.
Where it is
v0.2.0 is on PyPI as agent-sql-guard and the source, changelog and security policy are on GitHub. Two naming notes, because both have caught people. The import is sql_guard while the distribution is agent-sql-guard, and the unqualified name sql-guard on PyPI is an unrelated data-quality package by a different author. 0.2.0 is also the first release published to PyPI at all, because the name collision meant 0.1.x never shipped there.
The package classifiers say Development Status 4, Beta, which is the accurate description. The whole thing is roughly 1,400 lines across three modules, small enough to read end to end in an afternoon. For a component sitting on a security boundary I would treat that as a feature rather than an apology, and I would rather people read it than take this post’s word for anything.
If you are running an agent that writes SQL against anything sensitive, an hour spent trying to beat your own guard, whatever form it takes, is likely to pay for itself. The eight above are a starting list. If you find something in sql-guard, please use the disclosure route in SECURITY.md instead of a public issue: [email protected] or a draft advisory, with the SQL, the config, and the decision you expected against the one you got. Everything that is not a bypass is very welcome in the issue tracker.
Disclosure: sql-guard is developed and maintained by Sakura Sky and released under Apache-2.0. Sakura Sky uses it in client-facing agent work. There is no paid tier, hosted version or commercial product built on it. The findings described here come from an internal adversarial review of a deployment rather than a third-party security audit.
References
OWASP (2025) LLM01:2025 Prompt Injection, OWASP Top 10 for LLM Applications. OWASP Gen AI Security Project. Available at: https://genai.owasp.org/llmrisk/llm01-prompt-injection/ (Accessed: 18 August 2026).
Sakura Sky Engineering (2026) sql-guard: deterministic policy engine for LLM-generated SQL, v0.2.0. Available at: https://github.com/sakura-sky/sql-guard (Accessed: 18 August 2026).
Mao, T. (2026) SQLGlot: no-dependency SQL parser, transpiler, optimizer and engine. Available at: https://sqlglot.com/sqlglot.html (Accessed: 18 August 2026).
Willison, S. (2025) The lethal trifecta for AI agents: private data, untrusted content, and external communication, 16 June. Available at: https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/ (Accessed: 18 August 2026).

