ADR 0002: sqlglot AST guard, default-deny at the top level
Status: accepted (v0.1.0)
Context
The agent runs SQL that a language model wrote. The first line of defence is to refuse anything
that is not a plain read before it reaches Postgres. String and regex checks ("does it start with
SELECT?") are easy to defeat, and a check that only inspects the outermost statement misses a
Postgres feature: data-modifying CTEs. WITH x AS (DELETE FROM rental RETURNING *) SELECT * FROM x
is a single statement whose outer form is SELECT but which deletes rows.
Decision
sql_guard/ast_guard.py parses each query with sqlglot (Postgres dialect) and returns a
Decision(verdict, reason, detail). Only Forward reaches the database. In order:
- Unparseable input:
Deny(unparseable_sql). - Anything other than exactly one statement:
Deny(stacked_statements). - Default-deny at the top level: the root node must be
exp.Select. Everything else (INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CALL, COPY, EXPLAIN, SHOW, and anything sqlglot cannot classify) isDeny(not_a_select) without being enumerated. SELECT ... INTO:Deny(select_into).- Whole-tree walk:
root.find(...)over every node, including CTE bodies, for the denied typesInsert,Update,Delete,Drop,Alter,TruncateTable,Merge. Any hit isDeny(forbidden_node_in_tree). This closes the data-modifying-CTE gap above.Mergewas added after review showed a MERGE nested in a CTE parses without exposing itsDELETEaction as aDeletenode, so it would otherwise have been forwarded. - Denied functions by name:
root.find_all(exp.Func)over the same whole tree, checked case-insensitively againstpg_terminate_backend/pg_cancel_backend. Any hit isDeny(forbidden_function). Unlikepg_sleep/set_config/nextval(still forwarded and contained by lower layers, below), these two have no containment layer at all: on stock Postgres 16, one role can signal a backend running as the same role with no extra grant, so a Forwardedpg_terminate_backend/pg_cancel_backendlet anyagent_rosession kill any other concurrentagent_rosession (found live during review, see ADR 0005). There is no legitimate read-only use of either function, so this is a name-based deny rather than relying on ADR 0005's layers to contain it. - Otherwise
Forward(select_only).
The guard is fail-closed on a parse error and is checked before any connection is opened, so a denied query causes no database traffic. Red tests cover each deny class, including CALL, COPY, the CTE-DML case and the MERGE-in-CTE case.
Consequences and limits
- Default-deny is only true at the root. Nested nodes are checked against an enumerated
denylist, not an allowlist. A future or exotic write-capable node type that sqlglot models under
a
SELECTroot and that is not in that tuple would be forwarded. The other two layers (ADR 0005) exist because this one is not sufficient alone. - Over-blocking. A top-level
UNION/INTERSECT/EXCEPT(root isexp.Union, notSelect) and a top-levelVALUESare denied asnot_a_select, although they are reads. Wrapping in a subquery or CTE passes. The model sees the denial as an observation and can rewrite. - Functions are inspected only for the denylist above.
SELECT pg_sleep(10),set_config(...)andnextval(...)are still forwarded by the guard; they are contained by the statement timeout, the read-only transaction and the role's privileges respectively (ADR 0005), not by the guard.pg_terminate_backend/pg_cancel_backendwere forwarded the same way until this fix, but had no such containment (see point 6 above and ADR 0005), so they are now denied by name instead. Any other function not on the name denylist is still forwarded and relies on the lower layers. - Parser differential. The guard reasons about sqlglot's tree; Postgres parses the string itself. A string that sqlglot and Postgres read differently is the classic way to defeat this design. Nothing here rules that out beyond the other layers.
- MERGE-in-CTE. Whether Postgres 16 accepts a MERGE inside a CTE was not verified against a live server; the deny entry is defensive and costs nothing.
- Denials carry a reason string that becomes an observation, so the model can self-correct;
they also appear in
AgentResult.sql_log, which records every submitted well-formed query, including rejected ones.