Constraining Generated SQL to an Allow-Listed Function Set

Parse generated SQL and check every function and table against an allow list, so anything unrecognised is rejected rather than permitted — and keep the list honest over time.

The difference between an allow list and a deny list is what happens to something nobody thought of. A deny list permits it; an allow list rejects it. For SQL shaped by text a stranger typed, that difference is the whole control — and it only holds if the check reads a parse tree rather than the statement’s text. This guide covers building and keeping that list, the enforcement half of prompt-to-spatial-SQL generation.

When to Use This Approach

Any system where a generated statement reaches a database. The size of the list varies enormously; its existence does not.

Deployment List size Notes
Public-facing analytics A few dozen functions Tight, reviewed, read-only
Internal analyst tool A hundred or so Broader, still enumerated
Read replica of open data Wide Still an allow list, still no writes
Anything with private data Narrow Scoped tables matter more than functions
Allow list against deny list for an unrecognised functionAn allow list rejects anything it does not recognise, while a deny list permits anything its author did not anticipate.allow listdeny listunknown means rejecteda new function needs a decisioncoverage is provableunknown means permitteda new function arrives allowedcoverage is a hopeA deny list works perfectly against everything its author already knew about
The two are not variations on one idea. They differ precisely in the case you cannot enumerate, which is the only case a security control exists to handle.

Implementation

Parse first. A statement is checked by walking its tree, never by searching its text, because whitespace, comments and casing all defeat text matching while changing nothing about what runs.

import sqlglot
from sqlglot import exp

ALLOWED_FUNCTIONS = {
    "st_area", "st_astext", "st_buffer", "st_centroid", "st_contains",
    "st_distance", "st_dwithin", "st_intersects", "st_intersection",
    "st_isvalid", "st_length", "st_makevalid", "st_transform", "st_union",
    "count", "sum", "avg", "min", "max",
}
ALLOWED_TABLES = {"parcels", "rivers", "protected_areas", "schools"}


def check(sql: str, dialect: str = "postgres") -> list[str]:
    try:
        statements = sqlglot.parse(sql, read=dialect)
    except sqlglot.ParseError as exc:
        return [f"unparseable: {exc}"]

    if len(statements) != 1:
        return ["more than one statement"]

    tree = statements[0]
    if not isinstance(tree, exp.Select):
        return [f"not a read: {type(tree).__name__.lower()}"]

    problems = []
    for node in tree.find_all(exp.Anonymous, exp.Func):
        name = (node.sql_name() or "").lower()
        if name and name not in ALLOWED_FUNCTIONS:
            problems.append(f"function not allowed: {name}")
    for table in tree.find_all(exp.Table):
        if table.name.lower() not in ALLOWED_TABLES:
            problems.append(f"table not allowed: {table.name}")
    return problems

Two structural checks matter as much as the lists. Rejecting anything that is not a single statement closes the case where a benign read is followed by something else. Rejecting anything that is not a SELECT means the list never has to reason about writes at all.

The rejection message returned to the model should name the specific item, because that is what makes the next attempt succeed rather than repeat.

def to_model_feedback(problems: list[str]) -> str:
    return (
        "The statement was rejected: " + "; ".join(problems) +
        ". Rewrite it using only the functions and tables described in the schema."
    )
The four checks and what each one closesSingle statement, read only, allowed functions and allowed tables each close a distinct class of problem, and all four run against the parse tree before execution.one statementread onlyallowed functionsallowed tablesno stackinga second is nevera legitimate requestno writes at allthe list ignoreseverything but selectsenumerated, not filteredunknown is rejectedcoverage is provablescoped to the callernot just to the schemaprivileges back it upAll four are answerable from the parse tree — none of them requires running anything
Rejection costs a parse. Every check here runs in under a millisecond against a tree the generator already produced, which means there is no version of this worth skipping for performance.

Validation & Testing

Test with statements designed to slip past a text matcher, because those are what a text matcher would have missed.

BYPASS_ATTEMPTS = [
    "SELECT /* st_area */ pg_read_file('/etc/passwd')",
    "SELECT ST_Area(geom) FROM parcels; DROP TABLE parcels",
    "SELECT\n\tPG_SLEEP(10)",
    "select st_area(geom) from PARCELS_SECRET",
    "SELECT ST_Area(geom) FROM parcels UNION SELECT version()",
]


@pytest.mark.parametrize("sql", BYPASS_ATTEMPTS)
def test_rejected(sql):
    assert check(sql), f"should have been rejected: {sql}"


def test_ordinary_query_passes():
    assert check("SELECT ST_Area(geom) FROM parcels WHERE ST_DWithin(geom, %s, 500)") == []

Also assert that the parse failure path rejects. A malformed statement that produces a parse error must not fall through to execution on the theory that the database will reject it anyway — that reasoning is correct today and becomes wrong the first time somebody adds a fallback.

Gotchas & Edge Cases

Operators that are functions. Spatial operators like && and <-> do not appear as function nodes in most parsers. They need their own enumeration, and forgetting them leaves a gap that looks like thoroughness.

Schema-qualified names. public.st_area and st_area are the same function and different strings. Normalise qualified names before comparison, or the list will reject legitimate statements and someone will widen it in the wrong direction.

Common table expressions and subqueries. Tables referenced inside a WITH clause or a nested select are still tables. A checker that only inspects the top-level FROM misses them entirely, which is why walking the whole tree matters more than checking the obvious places.

The list broadening under pressure. Every addition is individually justified and the aggregate drifts toward permissiveness. Reviewing the whole list periodically, rather than each addition as it arrives, is the only review that sees what it has become.

Relying on the list alone. It is one layer. The connection underneath should be read-only and scoped so that a gap in the list is a bug rather than a breach, and the two together are what make the design survivable.

Where a text matcher fails and a parser does notComments, unusual whitespace, case differences and nested subqueries all change the text of a statement without changing what it does, and only a parser is unaffected.parse tree: comments, spacing and casing are already gone before the check runstext match: a comment between the name and its parenthesis defeats ittext match: a table inside a subquery is never inspectedThe parser removes the entire category of problem rather than enumerating it
This is why the parse is not optional. Each text-matching failure has an individual fix, and the set of them is unbounded — which is the situation a structural check exists to escape.

Generating the List Rather Than Writing It

A hand-written list drifts from the schema it describes. Generating it from the catalogue — the functions actually installed, the tables the connection can actually read — and then narrowing it by hand keeps the two in step and makes additions deliberate rather than accidental.

The generated starting point also answers a question that is otherwise guesswork: what is currently reachable. A connection with more privileges than anyone realised shows up immediately as a list far longer than expected, which is a finding worth having before it is a finding somebody else has. The narrowing pass is then a review of a real inventory rather than an attempt to imagine one.

The same generation should feed the schema description given to the model, so that what it is told about and what it is permitted to use are the same set. A model shown a table it cannot query will use it, get rejected, and try again — and the wasted round trips are entirely avoidable, since both descriptions come from the same source. Keeping them together also means that removing a table removes it from both places at once, which is the sort of change that otherwise gets done in one place and remembered in the other several weeks later.

Operating This Step Over Time

Count rejections by reason, and keep the counts separated by function and table rather than aggregated into a single total. A function rejected repeatedly is usually a legitimate capability the list has not caught up with, and the right response is to consider adding it; one rejected once is worth reading in full, because a single unusual attempt is the shape most worth understanding. Both are invisible without the counter and obvious with it, and the separation is what lets you tell a missing capability from an unusual request at a glance.

Re-generate the candidate list after every schema migration and diff it against the enforced one. New tables and functions appear silently otherwise, and the diff is a two-minute review that catches a table added for one purpose becoming reachable for all of them.

Keep the bypass test set growing. Every rejected statement that turned out to be interesting — an unusual construct, a nesting nobody had considered, a function reached through an operator — belongs in the fixtures, because a test written from a real attempt is worth several written from imagination. The set also documents what the check is known to handle, which is the question anyone reviewing it will ask first.

Watch for the check being skipped. The most common way an allow list stops working is not that it is broadened but that a new code path reaches the database without passing through it — a background job, an export, a debugging endpoint that was never meant to survive. Routing every statement through one function, and asserting in tests that no other call site exists, is the structural version of that discipline; a periodic search for direct execution calls is the cheap version.

Frequently Asked Questions

Does the parser need to match the database exactly?

Close enough that anything it accepts, the database interprets the same way. A parser that is more permissive than the database is safe — the database rejects what it does not understand. One that parses a construct differently is the risk, which is why the dialect should be set explicitly and why parse failures must reject rather than pass through.

What about user-defined functions?

Treat them exactly like built-ins: enumerated individually, never by schema or prefix. A rule allowing everything in one schema means every future function added there is permitted by default, which reintroduces the deny-list property the whole approach exists to avoid.

Should rejections be shown to the user?

Not the details. Naming the rejected function tells an attacker what to try next and tells an ordinary user nothing actionable. The reader gets a short sentence about being unable to answer that question and an invitation to rephrase; the specifics go to the log and to the model, which can act on them.

How does this interact with cost checks?

They are separate and sequential. The allow list decides whether a statement is permitted; the planner's cost estimate decides whether it is affordable. A statement can be entirely legitimate and ruinously expensive, and conflating the two produces either a list that rejects reasonable queries or a cost check nobody added.