深入分析为何在context window中的指令不等于可信执行,包括prompt injection的攻击机制,以及系统提示作为安全边界为何失效。
文本转 SQL 智能体是最容易演示的 AI 功能之一,也是最难通过安全审查的功能之一。非技术用户用英语提出一个问题,模型编写 SQL,仓库执行查询,结果表返回。演示只需要一个下午。但评审往往会在一个更难的问题上卡住:到底是什么阻止了智能体返回提问者无权查看的数据?
在我参与的评审中,第一个答案通常是系统提示词里的一句话。永不返回客户电子邮件地址。只能查询 orders 表。不要执行任何昂贵的操作。这些句子值得写,OWASP 针对提示词注入的第一条缓解措施正是以这种方式约束模型行为——用具体指令说明模型的角色和限制(OWASP, 2025)。我的论点不是说这些话没用,而是说当有人要求你证明这条规则确实有效时,这些话是错误的证据,因为位于上下文窗口中的指令与该窗口中所有其他 token 受到的力量是相同的。
Willison 通过类比 SQL 注入创造了"提示词注入"这个术语,他直白地描述了其机制:LLM 遵循内容中的指令,但无法可靠地根据指令的来源来区分其重要性(Willison, 2025)。OWASP 将提示词注入列为 LLM 应用 Top 10 首位,并对前景给出了罕见的直白评价:鉴于模型运作方式核心的随机性影响,是否存在任何万无一失的防范方法尚不明确(OWASP, 2025)。
在文本转 SQL 智能体中,注入面比用户的问题更广。工具结果会进入上下文,从信息模式拉取的列描述也会,以及在自由文本字段中写入指令的返回行。这些都可能携带让模型越过你写的规则的论据。
我的工作假设——我不会声称它不仅仅是一个假设——是:LLM 执行的规则是LLM 能够被说服不执行的规则。如果你需要向审计员证明一条规则有效,那条规则必须存在于你可以测试它并证明它错误的地方。
OWASP 自己的缓解措施列表在更下方也表明了这一点,建议用确定性代码验证是否符合预期输出,并在代码中处理特权函数,而不是将它们交给模型(OWASP, 2025)。这就是本文要介绍的设计。
一旦团队接受了系统提示词是多孔的这一事实,反射性的修复方案就是加一个审判者:一个较小的 LLM,用于查看生成的 SQL 并判定它是否安全。
我认为这作为一个检测性控制是合理的,但作为底线是不足的。它在每次查询时消耗 token,并给用户感知为延迟的路径增加了一个网络往返,这些都是平淡无奇的反对意见。我在意的一点是,审判者是一个概率分类器,而这些系统倾向于标榜的数字大约在 95% 左右。Willison 对护栏供应商类别的评价是,在 Web 应用安全领域,95% 是一个不及格的分数(Willison, 2025),我在这里也适用同样的标准。一个大多数时候都正确的控制可以放在你的底线之上,但我不会用这样一个东西来建造底线。
审判者真正有价值的地方在于解析器在结构上无法看到的类别,我会在下面讲到:通过无害列进行的重新识别、通过 JSON payload 内部的字符串字面量寻址的 PII、单独看都没问题但组合起来可识别的聚合。这些是语义问题,而语法对此没有意见。我的偏好是在预算允许的情况下,让审判者运行在确定性层之上,而不是取代它。
智能体生成的 SQL 是一个字符串。在它到达仓库之前,它是惰性的,完全可检查的,而且与自然语言不同,它有语法。这才是规则可以被不接受参数的东西执行的时刻。
sql-guard 就是我们对这种控制的尝试。它是一个策略引擎,位于模型输出和仓库客户端之间:它解析 SQL,对语法树运行有序的规则列表,并返回 allow、confirm 或 deny。guard 路径中没有 LLM。它采用 Apache-2.0 许可,纯 Python,只依赖 sqlglot,无需其他。安装 agent-sql-guard,导入 sql_guard,Python 3.11 或更高版本(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.reason
默认搭载六条规则。只允许单条 SELECT,因此 DML、DDL 和多语句 payload 会被拒绝,即使埋在子查询中。PII 列黑名单。任何范围内都不允许 SELECT *。不能枚举列的东西不行。表名单用完全限定名。还有一个成本上限,有三个阈值:0.10 美元以下自动执行,之间需要确认,20.00 美元硬上限或 10 GiB 字节计费上限以上则拒绝,所有这些都是你可以调整的默认值。
选择解析而不是模式匹配是承载重量的选择。对 SQL 做正则表达式是出了名的脆弱,下面描述的大多数绕过方式对于正则表达式来说都是微不足道的,而对于不够仔细的 AST 遍历来说仍然可达。sqlglot 处理方言表面(Mao, 2026)。BigQuery 是默认的,也是实际使用最重的。Snowflake、Postgres、Trino、DuckDB、ClickHouse 和 MySQL 都有测试覆盖,Presto 与 Trino 共享解析器。sqlglot 其余三十多个方言应该都能工作,只是没有经过实战检验。
这里有一个注意事项,而不是在末尾的局限性章节里,因为它决定了这一切是否构成一个边界。调用者必须记得调用的库只是一个约定。宿主进程的仓库身份是相同的,无论 guard 是否运行了,所以如果同一进程中另一个代码路径可以直接到达客户端,那 guard 更接近于一个 lint 而不是控制。把凭证放在受保护的客户端后面,放在智能体进程无法绕过的单独服务账户或代理中,这才是将约定转化为执行的关键。
解析器不会被说服放弃规则。它仍然可能对规则涵盖的范围判断错误,而本文其余部分就是八个完全这样的例子。
v0.2.0 的存在是因为对已部署智能体进行内部对抗审查时,发现了两种让黑名单列通过 guard 的方法。修复这两个问题又暴露了六个。发布版关闭了八个,每个都在 0.1.1 中用复现查询确认,然后才有人动手修复,每个都有在旧代码上会失败的回归测试(Sakura Sky Engineering, 2026)。变更日志将工作细分为十个条目,因为其中两个类别需要单独的修复。
别名清洗。在 WITH 子句引入的公共表表达式(CTE)内部重命名被拒绝的列,然后从外层查询投影该别名。每个 CTE 有自己的作用域,而规则调用的辅助函数只读取最外层投影列表,所以外层 select 命名为 city 看起来是干净的。
WITH c AS (SELECT billing_city AS city FROM `p.d.orders`)
SELECT city FROM c
派生表、UNION ALL 分支和通过两个 CTE 的多跳别名链都以同样的方式生效。
内层作用域的星号。星号规则也只针对最外层 select 运行,所以 CTE 体内或派生表内的 SELECT * 没有被触及。
包装构造内的星号。一个效果相同的独立 bug。检查审视投影的根节点,这意味着 Snowflake 的 OBJECT_CONSTRUCT()、DuckDB 的 COLUMNS()、ClickHouse 的 * APPLY(f) 和 Trino 的 ROW(c.) 都通过了。现在是深度遍历,COUNT() 是明确的例外。
限定的 t.*。在 sqlglot 中,限定星号解析为包装 Star 的 Column 节点,而不是裸 Star。 isinstance(projection, exp.Star) 检查直接跳过它,在顶层以及其他任何地方都是如此。
ClickHouse COLUMNS('regex')。展开为任意列集,解析后在树的任何地方都没有 Star 节点可匹配。
NATURAL JOIN。在两个表碰巧共享的列上进行连接。没有模式自省,guard 无法排除其中包含被拒绝的列。
列名从未成为 Column 节点。sqlglot 在多个位置将内容解析为裸 Identifier,因此一个收集 Column 节点的扫_PASS遗漏了它们:JOIN ... USING (email)、AS g(email) 形式的列别名,以及 STRUCT('x' AS email) 的字段名。USING 这种情况曾是一个可用的单查询值预言机。
聚合函数作为全面豁免项。所有聚合函数都被计为 PII 中性,因此 MAX(email)、MIN(email)、ARRAY_AGG(email)、STRING_AGG(email) 和 ANY_VALUE(email) 都返回了真实值。现在只有归约为派生统计量的聚合函数才能豁免。
全行别名引用。这一条不在原始报告中。它是在修复其他问题时发现的,而且是八个中最严重的一条。
SELECT c FROM `p.d.orders` AS c
这被解析为一个名为 c 的普通列。黑名单中没有要匹配的拒绝名称,星号检查没有找到星号,护栏自动执行了它。从功能上讲,它比自第一天起一直在拦截的 SELECT * 更强大,但在语法上与恰好名为 c 的列选择无法区分。修复还涵盖了 TO_JSON_STRING(c)、ARRAY_AGG(c)、STRUCT(c)、无别名的 SELECT tbl FROM tbl 形式、CTE 和派生表名,以及 VALUES 和 PIVOT 别名。
让这条规则变得可用比让它变得严格需要更多的心思。如果仅凭裸名称冲突来拒绝,会破坏 WITH revenue AS (SELECT ..., SUM(x) AS revenue ...) SELECT revenue FROM revenue,这是一个主流用法,所以规则从 AST 来解析歧义:表只贡献它可寻址的名称,CTE 发布它自己的输出名称。
这些绕过返回了成功状态
八个绕过中的大多数没有返回 deny。它们返回了 confirm,而 confirm 是静态评估在没有任何规则触发时返回的状态。
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)),
)
所以没有接近拦截的情况,也没有什么可检测的。八个查询触及了被拒绝的列,护栏却报告静态检查已通过——与它报告一个完全正常的查询时用词一模一样。全行别名穿过了成本门并执行了。
如果你在为这个组件构建遥测,有一件事值得了解:GuardOutcome 在两个评估阶段之间共享,在每个阶段中承载不同的含义。在 evaluate_static 中,confirm 是通过。在 evaluate_cost 中,它意味着运行前询问用户,介于自动阈值和硬上限之间。
一个失败时默认关闭的护栏往往会产生一个支持工单并在当天下午被修复。一个失败时默认开放的护栏往往几乎不产生任何东西,这让「去查看」成了任何人发现问题的唯一方式。这种不对称是我主张将这类组件的对抗性审查作为常规工作的理由,也是为什么现在每个被修复的绕过都带有一个针对修复前版本会失败的测试。这五个文件中相当一部分测试的存在是为了保持这八个漏洞处于关闭状态。
修复破坏了我们自己的演示
收紧护栏意味着以前能工作的查询现在不能了,0.2.0 版本包含有意的破坏性行为变更。
最清晰的受害者是随项目捆绑的作为工作示例的身份解析查询。它在 CTE 内部规范化 email 和 mobile,仅投影 COUNTIF 聚合,这是分析中一种看起来很谨慎的普通模式。现在在两种 PII 模式下都被拒绝:CTE 作用域投影了被拒绝的列,而 COUNTIF(email_norm = 'target') 本身就是一个值预言机。
WHERE 子句是一个预言机
sql-guard 有两种 PII 模式。"reference",默认值,拒绝在查询中任何位置提及被拒绝的列。"project" 只拒绝投影,检查范围覆盖每个作用域。
仅检查投影是直观的设计,但它不成立,因为谓词中被拒绝的列永远不会出现在输出中,同时仍然在回答关于它自己的问题。
SELECT COUNT(*) FROM `p.d.orders` WHERE billing_city = 'Columbus'
用 LIKE 'a%'、然后 > 'm' 再跑一遍,你就在对你永远不被允许读取的值做二分搜索。取决于基数,几个查询就能恢复它。GROUP BY、HAVING 和 ORDER BY 以相同方式泄露。行数是信道,而只检查投影的规则看不到它。
因此有了严格的默认值。如果一个部署真的需要对被拒绝的列有谓词访问权限,pii_mode="project" 在那里,权衡在文档中说明。缩小黑名单,或者将智能体指向黑名单未覆盖的预屏蔽视图,通常是更好的做法。
有一个细微之处很容易搞反,一位仔细阅读了变更日志的读者就是这样:聚合不是安全港。COUNT、SUM、AVG 及其同类在 pii_mode="project" 下才被视为 PII 中性。在默认模式下没有聚合函数豁免,因为默认模式的核心就是智能体根本不能学习这些值,而 COUNT(*) ... WHERE email = ... 正在学习它们。
解析级 SQL 护栏的局限性
这些局限性是结构性的。我更愿意公开它们,而不是让别人自己去发现。
JSON、VARIANT 或 STRUCT 载荷内部的 PII 未被覆盖。JSON_VALUE(payload, '$.email') 只命名了 payload;字段名是一个字符串字面量,由引擎在运行时解析。在包含它的列上加入黑名单。
两个全行读取在设计上保持开放。选择一个完整的 STRUCT 列,以及对结构体数组的 UNNEST 别名,两者都返回每个字段而不命名其中一个。在解析时,两者都无法与必须保留的标量数组形式区分开来。相同的补救方法:在包含它的列上加入黑名单。
通过非 PII 列重新识别不在范围内。如果 uid 与一个人一一映射,阻止 email 不会阻止与外部数据集的相关性对比。
侧信道依然存在。行数、试运行字节数和错误信息都在被拒绝的值上携带比特信息,即使每个直接引用都被拒绝。上述预言机是我们关闭的版本,而这个家族比这个修复更大。
没有模式自省。如果你允许一个表,护栏就采信你的话。这条约束是为什么 SELECT * 在任何地方都被拒绝而不是被推理的原因。
成本上限约束的是查询,而不是支出。evaluate_cost 按调用构建。一个在重试循环中的智能体发出两千个查询,每个九美分,什么都不会触发。累积暴露需要仓库端的最大按字节计费和预算警报。
它不是一个授权层。身份、IAM 和行级安全性在其之外。它可以批准一个正确配置的仓库本会在身份层面拒绝的查询。
它是一个边界,仅当它是凭据的唯一路径时。上面已经讨论过,在我的经验中这也是这个控制被降级为建议的最常见方式。
变更日志还记录了两个我们尚未修复的已知问题和一个不一致性:顶级 EXCEPT DISTINCT 或 INTERSECT 目前被拒绝为非 SELECT,这失败关闭,是一个可用性 bug 而不是安全问题;allowlist 违反在基于 decision.reason 构建的遥测中报告不足,因为那条规则最后运行;PII 哈希的两种拼写在处理上不一致,方向是拒绝。任何在评估这个工具的人最好在阅读那一节的同时阅读 README。
纵深防御,以及纵深防御建立在什么之上
仓库端列级和行级安全性是大多数问题更持久的答案。敏感列上的策略标签、掩码策略、作用域为调用主体的行访问策略:与数据同在的强制执行,适用于每个客户端,不管查询来自智能体、BI 工具还是某人的笔记本。如果一个团队能做到这一点,我会推动他们去实现。
sql-guard 填补的空白是从做出那个决定到拥有它之间的差距。仓库端控制需要协调的 schema 工作、数据分类练习(通常只完成了一半),以及来自拥有你无权访问的表的团队的签字。在我观察过的项目中,这通常需要数个季度而不是几周。黑名单和白名单在配置文件中是一个下午的事,它给你提供了列安全所没有的成本上限,而且一旦仓库工作落地,它就作为第二层继续工作。
消息边界护栏如 NeMo Guardrails 或 LangChain 的属于同一图景。它们监视对话中的意图,这个监视执行时的查询,而它们的失败模式在我看来足够不同,同时运行两者通常是值得的。
v0.2.0 已发布在 PyPI 上,包名为 agent-sql-guard,源代码、更改日志和安全策略均在 GitHub。需要说明两个命名细节,因为都曾让人踩过坑:import 时包名是 sql_guard,而 PyPI 上的发行名是 agent-sql-guard;PyPI 上不带前缀的 sql-guard 则是另一位作者开发的无关数据质量包。0.2.0 也是首个发布到 PyPI 的版本,因为名称冲突,0.1.x 从未推送上去。
包分类器标注为开发状态 4(Beta 版),这是准确描述。整个项目约 1,400 行代码,分布在三个模块中,小到可以花一个下午从头到尾读完。对于一个位于安全边界上的组件,我认为这是优点而非缺点,我更希望读者直接去读源码,而不是听信这篇文章的说辞。
如果你正在运行一个会生成 SQL 的 AI 智能体来访问敏感数据,花一个小时尝试攻破你自己的护栏(无论采取何种形式)很可能会物超所值。上述八条只是一个起点。如果你在 sql-guard 中发现了问题,请使用 SECURITY.md 中的披露渠道,而不是提交公开 issue:security@sakurasky.com 或起草一份 advisory,提供 SQL、配置信息,以及你预期的决策与实际得到的决策对比。凡不属于绕过的发现,都非常欢迎在 issue 跟踪器中报告。
利益披露:sql-guard 由 Sakura Sky 开发并维护,基于 Apache-2.0 许可证发布。Sakura Sky 在面向客户的 AI 智能体工作中使用它。目前没有付费等级、托管版本或基于它构建的商业产品。本文描述的发现来自内部对抗性审查,而非第三方安全审计。
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).