用sqlglot为LLM生成SQL加只读护栏

用sqlglot解析SQL并遍历语法树,构建只读查询护栏,防御注入与副作用。
文章指出,给LLM连接数据库时,简单的字符串前缀检查无法防御分号链、CTE藏匿写操作、DuckDB文件读取等攻击。更稳的做法是用sqlglot将SQL解析为方言感知的AST,再按六步审查:要求单条语句且根节点为查询、遍历整棵树拦截禁用节点、按函数名屏蔽文件与网络读取、带CTE感知的表允许列表、用包裹而非追加方式施加行数上限、以行数求和或相乘的成本代理检测笛卡尔积。护栏只防损害不判对错,正确性需通过比较结果集而非SQL文本来度量,且必须配合只读数据库角色使用。
给LLM连接数据库,迟早会收到DROP TABLE或没有WHERE的DELETE——有时是用户要求,有时是列值里藏了提示注入,有时只是模型"灵机一动"。简单的sql.strip().lower().startswith("select")防御至少会在三种情况下失效:SELECT 1; DROP TABLE orders、用CTE藏INSERT、以及DuckDB中SELECT * FROM read_csv('/etc/passwd')。
更稳的做法是先解析SQL,再审查语法树。开源库sqlglot能生成方言感知的AST,下面是一套基于它实现的只读查询护栏,以DuckDB为例。

核心设计:解析后审查语法树
第一步:单条语句,根节点为SELECT
用sqlglot.parse(sql, read=DIALECT)得到语句列表,过滤空语句,要求恰好一条。根节点必须是exp.Select、exp.SetOperation或exp.Subquery。解析失败的SQL直接拒绝——如果解析器看不懂,就不该让它执行。
stmts = [s for s in sqlglot.parse(sql, read=DIALECT) if s is not None]
if len(stmts) != 1:
raise GuardError("multiple_statements", ...)
tree = stmts[0]
if not isinstance(tree, (exp.Select, exp.SetOperation, exp.Subquery)):
raise GuardError("not_select", ...)
注意错误码code的设计——它不仅是拒绝原因,还是后续做评测时的标注数据。
第二步:遍历整棵树,而非只看根节点
根节点是SELECT还不够,数据修改型CTE和SELECT INTO都可能藏在深处。用tree.walk()遍历每个节点,命中禁用列表就拒绝。列表包括Insert、Update、Delete、Merge、Create、Drop、Alter、TruncateTable、Command、Copy、Set、Use、Pragma、Attach、Detach、事务类、权限类等。
sqlglot跨版本会重命名节点类,因此用hasattr(exp, name)过滤构建元组,并锁定测试过的sqlglot版本,升级时重跑护栏测试。
CTE(Common Table Expression,公共表表达式)即 WITH 子句定义的命名子查询。它之所以成为注入写操作的温床,是因为 SQL 标准允许在 CTE 体内嵌入数据修改语句,例如 WITH x AS (DELETE FROM orders RETURNING *) SELECT * FROM x。这类语句的语法树根节点仍然是 Select,仅检查根节点类型会直接放行。同理,SELECT ... INTO new_table 在部分方言中会创建新表,其根节点也表现为查询。只有用 tree.walk() 深度优先遍历每个节点,才能捕获藏在中层的 Delete、Insert 等节点。这也解释了为何护栏必须做整树审查,而不能止步于验证最外层语句的类型。
第三步:屏蔽文件与网络读取函数
DuckDB的read_csv、read_parquet、glob最隐蔽:表面是SELECT,实际是文件系统侧信道。按函数名屏蔽,同时拦截FROM 'data.csv'这种字面量表引用。
_BLOCKED_FUNCS = {"read_csv", "read_csv_auto", "read_parquet", "read_json",
"read_text", "read_blob", "glob", "parquet_scan", "query"}
如果目标是Postgres或Snowflake,黑名单会不同(pg_read_file、COPY、stage等),原则一致:枚举你所使用引擎的副作用入口,再叠加表允许列表。
DuckDB 将文件当作表来查询的能力来自其「替换扫描」机制:read_csv('path')、read_parquet('path') 等表函数在查询计划中直接读取本地或远程文件,无需预先建表。除此之外,DuckDB 还支持直接用字符串字面量引用文件,如 FROM 'data.csv',解析器会自动将其展开为对应的读取函数。这意味着攻击者只要能让模型生成一个看似无害的 SELECT,就可能读取服务器上的任意文件(如 /etc/passwd、SSH 密钥)或通过 glob 探测目录结构。这类侧信道不会修改数据,因此只读数据库角色也拦不住它们,必须在 SQL 层面按函数名和字面量表引用双重屏蔽。
第四步:带CTE感知的表允许列表
遍历exp.Table节点,要求表名在允许集合中。CTE名在语法树里看起来就是表,必须先收集exp.CTE的别名并排除,否则每个WITH查询都会被误杀。
第五步:模型无法抬高的行数上限
不要往用户SQL后面追加LIMIT——用户可能已有LIMIT 10000000。正确做法是把整个查询包一层:
def _cap_rows(tree, max_rows):
inner = tree.sql(dialect=DIALECT)
return f"SELECT * FROM ({inner}) AS _capped LIMIT {int(max_rows)}"
内层可以调低上限,但抬不高。执行tree.sql()重新生成的SQL而不是原始字符串,保证"验证的"就是"执行的"。
这里的关键区别在于「追加」与「包裹」。若在用户 SQL 末尾拼接 LIMIT N,当原查询已含 LIMIT 10000000 时会形成语法错误或语义歧义;而把整条查询作为子查询包裹后再施加外层 LIMIT,外层限制始终生效,内层更严格的上限可以保留,内层更宽松的上限则被外层压制,因此模型无法通过在自己的 SQL 里写大 LIMIT 来突破。另一个要点是执行 tree.sql() 重新序列化后的 SQL 而非原始字符串:解析器与执行器对同一段文本可能有不同理解(注释、方言差异、隐式类型转换),重新生成能保证被审查的语法树与实际执行的语句严格对应。
第六步:检测笛卡尔积的成本代理
引擎的EXPLAIN不一定便宜、可比或可用。一个确定性的代理足够有效:统计每张被引用表的行数后求和,遇到没有ON或USING的join就改为相乘。
total = total * max(n, 1) if cartesian else total + n
if max_cost is not None and total > max_cost:
raise GuardError("cost_cap", ...)
它很粗糙,会高估一些查询、低估另一些,但能把"全表交叉连接"变成拒绝而不是事故。
度量正确性:执行准确率
护栏只防损害,不判断对错。判断对错要比较结果集而非SQL文本——两种截然不同的SQL可以同样正确。写一个result_sets_equal函数,浮点数先四舍五入到6位再比较,避免追逐1e-12的幽灵差异。
黄金用例是问题加参考查询,需要顺序时标注ordered: true。运行agent、运行golden SQL、比较结果。护栏拒绝会以错误码形式出现为失败,从而区分"模型写了DDL"和"模型join写错了"。
护栏做不到什么
诚实面对局限:它不能替代只读数据库角色,两者应同时使用。它不理解行级安全,无法判断一个看似合法的查询是否泄露了用户不该看的数据,成本代理也不是查询规划器。把护栏当作一层防御,并维护一个小型已知坏查询测试文件(分号链、CTE包裹的insert、read_csv),每次升级sqlglot时都跑一遍。
作者已将这套方案打包为Data Analyst Agent Kit,包含完整禁用列表、DuckDB演示仓库、Vega-Lite图表输出、离线可跑的确定性mock LLM,以及执行准确率评测框架。如果只想要思路,上面的代码足够在一个下午内自建:锁定sqlglot版本,先写坏查询测试,保留只读数据库角色。
只读数据库角色是护栏之外的另一道独立防线:在数据库侧用 GRANT SELECT 创建专用账号,即使护栏被绕过、生成了 DROP TABLE,数据库也会以权限错误拒绝执行。两者互补——护栏在应用层尽早给出可读的错误码并阻止查询到达数据库,数据库角色则在护栏本身有 bug 或被升级破坏时兜底。行级安全(Row-Level Security)则是数据库原生能力,能在行粒度限制用户可见数据;护栏审查的是 SQL 语法结构,无法知道某条合法 SELECT 返回的行是否属于当前用户有权查看的范围,这类判断只能交给数据库的角色与策略配置。
相关推荐

Claude Code免费不限量方案:Agnes AI+CC Switch配置指南
无需付费订阅,用免费AI平台Agnes AI配合开源工具CC Switch为Claude Code提供后端,实现文本、图片、视频模型不限量免费调用。本文梳理安装配置全流程、实测效果与使用边界,帮助开发者低成本上手Claude Code。

国内新手零成本上手Claude Code完整指南
面向国内新手的 Claude Code 入门指南:两种安装方式(命令行与 IDE 插件)、用国产免费模型 LongCat 替代官方 API 的完整配置步骤,以及用自然语言开发小工具的实操演示与技巧。

当NSA都介入:开源CVE与API密钥劫持背后的AI供应链危机
一天内多个开源项目曝出严重CVE,PHP漏洞惊动FBI,Railway与OpenCode遭入侵致API密钥被窃。本文拆解漏洞窗口、AI加速利用与API密钥黑市背后的AI供应链危机,并给出开发者防御清单。