认证回答的是"是谁"。授权回答的是"他们被允许做什么"。两者都没有回答这个具体操作是否真的是有意为之。

这正是我想测试的缺口。我构建了一个小型 AI 智能体,并针对多个失败用例进行了测试,直到得出一个我有信心的结果。我也把这一部分包含进来,因为假装一切都在第一次尝试时就成功,本身就是另一种形式的虚假声明。

披露:Arcjet委托撰写了本文。我选择了角度、编写了代码,并独立运行了测试。Arcjet 审阅了技术大纲的事实准确性,而非结论。

AI 智能体中的授权缺口

本系列的前文我曾论证过,Server Action 不是一次函数调用,而是一个披着便捷语法的公开 POST 端点。那篇文章讲的是谁能触达该 action,以及他们是否被允许。这一篇讲的是一个因模型而彻底改变的问题。一旦由模型来决定是否调用该 action,"被允许"就不再是全部的故事了。

人类点击按钮是一种信号。模型根据文档内容生成工具调用是另一种信号,而前文中的所有检查——所有权检查、角色检查、以及它前面的代理——都不是为了区分这两者而构建的。这不是那篇文章论证的缺陷。这是三层模型从未被要求回答的另一个问题。

实验设置

我构建了一个小型 Next.js 研究助手。它读取文档、进行总结,如果文档提到提交报销,它就调用一个fileExpenseReport工具来完成。故意没有做得比这更花哨。我希望安全行为易于推理,而不是被无关的复杂性所掩盖。

技术栈:Vercel AI SDK、Gemini 作为模型、Arcjet 的 Guard SDK接入在两个独立的位置:

import { launchArcjet, detectPromptInjection } from "@arcjet/guard";
import { guardTool } from "@arcjet/guard/vercel-ai/v7";

detectPromptInjection在模型看到入站文档之前对其进行筛查。guardTool包裹了实际的工具调用。它们是两个独立的执行点,回答两个不同的问题,而不是一个检查承担双重职责。把它们合并成一个检查正是整篇文章所讨论的错误,所以我故意让它们保持分开。

下面的一切都是我本人账户的真实输出。合成账户、合成数据、真实的 Arcjet 决策。这里的"数据库"是一个桩,每次插入都会向控制台记录日志,所以这里的"数据库写入"意味着对该桩的一次调用。

合法基线

第一次运行:一份普通文档、一笔普通费用,没有任何对抗性内容。模型读取了它,调用了工具,数据库写入顺利完成。这就是对照用例,是在其他一切都被锁定的同时必须保持正常工作的东西。

攻击一:显而易见的注入

接下来,我植入了一份带有直接嵌入指令的文档,其中指示智能体将一笔费用记入一个实际上没有人问及的账户。detectPromptInjection在模型获得任何机会之前就捕获并拒绝了该请求。Arcjet 仪表盘独立显示了同一事件,标记为PROMPT_INJECTION,夹在其他干净的条目之中。

Arcjet Activity log showing a document.received request denied with reason PROMPT_INJECTION among otherwise allowed entries

来自同一测试夹具早期、尚未完成的一次运行中的一件事:注入曾有一次绕过了检测,而模型自行拒绝采取行动。我不把这算作安全控制。模型今天拒绝做某事,并不意味着它明天也会拒绝,而把 LLM 的良好判断当作你实际的安全边界,正是我构建整个测试所要避免的错误。

植入文档是我实际构建并测试的版本,但它只是更广泛类别中的一个实例。只要驱动决策的内容来自用户自己并未输入的地方——智能体总结的邮件线程、它抓取的页面、前一次工具调用的输出馈入下一次调用——同样的缺口就会出现。我只有文档这一案例的真实结果。我不会声称我测试了其他情况。

攻击二:真正重要的那个

显而易见的注入是容易的情况。有趣的是改述版本,没有"忽略之前的指令",没有命令式覆盖,只是把同样的底层请求表述成一条普通的业务备注。

detectPromptInjection让它通过。模型尝试了工具调用。而动作边界——工具自身处理程序内部的一个普通允许列表检查——拒绝了该账户,没有留下任何数据库写入。execute()handler,拒绝了该账户,没有留下任何数据库写入。

这种对比正是本文的核心。检测与授权回答的是不同的问题。检测器问的是:这段内容看起来可疑吗?而工具边界必须问一个完全不同的问题:这次特定的调用、带着这些特定的参数,现在真的应该执行吗?一个干净的检测结果并不能回答第二个问题,而把你的安全建立在它能回答这一假设之上,正是改写式攻击得以通过的原因。

Terminal output showing the baseline request executing, the obvious injection blocked at injection screening, and the paraphrased injection reaching the tool but being denied at the action boundary with no database write

在副作用发生之前对 AI 工具调用进行授权

阻止这次特定攻击的检查刻意做得很朴素:工具自身内部的一个普通应用层允许列表execute()function。这里值得说得精确一些,因为很容易把两者混为一谈:guardTool是围绕工具调用的 Arcjet 包装层,而下面的允许列表是我自己的应用逻辑。它只在一次调用已经通过guardTool之后才运行,紧挨在数据库写入之前,作为一项独立的、确定性的检查。

if(!ALLOWED_ACCOUNTS。includes(input。accountId)) {
  return {
    denied: true,
    boundary: "action",
    reason: "account_not_allowed",
    message: `Account${input。accountId}不在允许列表中`,
  };
}

我返回一个结构化结果,而不是抛出异常。函数在数据库写入之前退出,该结果给模型一个清晰的拒绝解释。如果模型重试,同样的允许列表检查会再次运行。

没有奇特的 API,没有巧妙的抽象。这正是重点。这项检查不需要多么复杂。它需要处在正确的位置:在检测之后,在模型的决策之后,紧挨在副作用之前。

工具调用是模型提出的一个建议。成功的副作用是应用做出的一个决定。这两者不是同一个事件,而把它们当作同一个事件,正是一个看起来干净的请求变成未经授权的数据库写入的原因。

重新运行完整的矩阵

我确认了这一模式在该组其余样本中同样成立:一份措辞听起来像指令、本不应触发误报的良性文档,确实没有触发误报,完全没有尝试任何工具调用。一份带有模糊指令的干净文档,任何地方都不存在注入模式,仍然在操作边界被拒绝。最后这一点本身就很重要,它证明仅靠检测无法捕获它,因为根本没有可供检测器捕获的东西。

Terminal output showing the benign false-positive check producing no tool call, and the clean-content ambiguous-instruction case reaching the tool but denied at the action boundary

为什么常规的分层防护无法填补这一缺口

这些都不能替代代理、WAF 或 RBAC。它不是其中任何一个的替代品。但值得精确说明每一个究竟解决了什么问题。代理或 WAF 看到的是一次干净的、已认证的 POST 请求。RBAC 正确地返回“是,该用户被允许提交费用报销”。两者都无法洞察这一次特定的调用、带着这些特定的参数,是否真的是用户的本意。它们在解决另一个问题,而且解决得很好。它们只是解决不了这个问题。

远程策略的弯路

我还测试了Arcjet 的远程策略,Arcjet 将其记录为一种无需重新部署应用即可集中更改执行逻辑的方式。

第一次运行时尝试并不顺利。我这边的一处配置不匹配需要先修复,策略才能按预期运行。

修正之后,并临时扩大本地允许列表使其不可能成为做出决定的因素,同一个正在运行的应用产生了两种结果:

被允许的账户,策略处于 Live 模式:模型使用策略明确允许的账户调用了工具,Arcjet 返回ALLOW,数据库写入发生了。

Arcjet Activity detail showing an ALLOW decision for the expense.filed policy, with accountId and amount as required inputs evaluated by Arcjet Cloud

被拒绝的账户,相同的代码、相同的部署:模型使用另一个账户调用了工具,返回的响应是DENY,原因INPUT_CONSTRAINT,没有错误,没有回退行为,是一次真正的策略匹配。没有任何内容写入数据库。

Arcjet Activity detail showing a DENY decision for the expense.filed policy, with the policy expression set to Deny in Live mode

这正是我想通过远程策略验证的:关于允许哪些账户的决定存在于 Arcjet 中,而不是在我的应用代码里。同一个运行中的服务器,无需更改代码,就根据我在中心管理的策略允许了一个账户并拒绝了另一个。根据 Arcjet 的文档,编辑该策略无需重新部署即可改变执行结果,不过 SDK 会缓存策略并每隔几分钟重新检查一次,因此更改并非即时生效。我没有测试两次运行之间编辑策略的情况。

工具调用与已执行不是一回事

这一点值得单独说明,因为它是整篇文章所依赖的核心主张。当你检查是否发生了工具调用时,你检查的是模型是否尝试了某件事。这与检查底层操作是否已执行并不相同。被拒绝的调用仍可能在你的日志中显示为一次工具调用。在我的测试中,它从未到达数据库写入这一步。同样的区分对于你的工具实际执行的任何有后果的操作都很重要,无论是数据库写入、外部 API 调用、发送邮件还是支付。如果你的日志记录将这两者混为一谈,你可能会在安全检查实际上只是记录尝试而非结果时,误以为它正在正常工作。

如果被拒绝的调用仍出现在你的日志中,值得事先决定你此时究竟想记录什么:

{
  toolCall: "fileExpenseReport",
  decision: "DENY",
  boundary: "action",
  reason: "account_not_allowed",
}

以这种方式组织,意味着一次尝试并被阻止的调用在你自己的遥测数据中是明确无歧义的,而不是你之后必须通过将工具调用日志与数据库写入交叉比对来重建的东西。

PII 是另一种边界

提示注入检测和授权并不能解决与敏感数据泄露给模型或下游工具调用相同的问题。如果文档包含电子邮件地址、电话号码或政府身份证件,检查模型的响应已经太晚了,数据已经越过了提供商边界。

我没有在本项目的矩阵中对此进行真实测试,所以我不会把它作为实验结果来呈现。但值得明确指出而不是略过,因为这里的边界与本文其他所有地方都不同。它不是在副作用发生之前,而是在敏感数据即将完全离开应用程序控制范围的任何位置:文档发送给模型提供商、工具输出反馈到模型上下文、某个值即将出现在日志中。

Arcjet 的 Guard SDK 还为智能体和工具路径提供本地敏感信息检测,因此检测可以在敏感内容跨越另一个边界之前在进程内完成。检测告诉你存在敏感内容。脱敏让模型在没有原始值的情况下继续运行。当某个值根本不应离开应用程序时,阻止是正确的选择。

自动化流量是另一个独立的问题

还有一个值得提及的边界,因为它回答的问题与上述所有内容都真正不同。我向一个小型 API 路由发送了一个真实的curl请求,使用的是同一个 Arcjet HTTP 边界客户端,然后发送了相同的请求,但将其 User-Agent 改为看起来像普通浏览器。两者都被作为机器人拒绝。仅更改 User-Agent 并没有改变结果。我无法准确说出哪些信号促成了这一决定,所以我不会去猜测其机制。

我将其单独列出,因为这是一个不同的问题。一个经过身份验证、获得授权、看起来完全合法的请求,仍然可能是自动化的并且在大规模下具有滥用性。请求级别的机器人检测和速率限制属于 HTTP 边界,与应用程序级别的检查并列,而不是替代它们。

这种模式在哪里帮不上忙

一个合法授权的用户请求真正有害的事情,这些机制都无法捕获。这不是提示注入问题,而是业务授权问题,定义谁被允许做什么仍然完全是你的应用程序的责任。

而模型自身的判断并不能作为安全控制来依赖,无论它在某次运行中看起来多么可靠。我曾见过模型在一次运行中拒绝执行不良指令,却在另一次运行中遵从了一条等效的指令——只是措辞不同,它此前没见过。这正是为什么检查必须放在每次都以相同方式运行的代码里,而不是依赖模型碰巧做出的决定。

运行时强制执行也并非没有代价。每增加一项检查都会带来处理开销和运维复杂度,而远程策略评估又引入了另一个需要显式处理故障的依赖项。正确的权衡取决于该操作的影响有多大,以及应用能容忍多少延迟。

实验实际证明了什么

测试 结果
合法请求 调用了工具,数据库写入成功
明显的注入 被检测机制拒绝,并在仪表盘中得到佐证
改述的注入 通过了检测,在操作边界被拒绝
良性内容,命令式语言 未尝试调用工具
干净内容,指令含糊 在操作边界被拒绝,且不存在注入模式
远程策略,允许的账户 已评估,允许,写入了数据库
远程策略,被拒绝的账户 已评估并被远程策略拒绝,未写入数据库
真实的自动化请求 被判定为机器人而拒绝;更改 User-Agent 并未改变结果

一种实用模式

如果你正在构建任何模型可以触发有后果操作的东西,请在此类操作之前立即放置一个显式的应用层检查:

  • 资金转移
  • 删除数据
  • 更改权限
  • 发送外部通信
  • 退款
  • 生产环境变更

身份验证和授权仍应像往常一样在上游进行。这项检查不能替代其中任何一个。它是一个具体而狭窄的边界,就设在一旦出错就会真正造成损害的操作之前。

在 AI 智能体能够造成副作用之前

  • 用户是否已通过身份验证?
  • 用户是否有权访问此资源?
  • 影响模型的内容是否被视为不可信?
  • 工具调用是否经过独立授权?
  • 是否检查了实际的工具参数,而不仅仅是发生了调用这一事实?
  • 拒绝是否发生在副作用之前?
  • 敏感数据是否会离开应用程序?
  • 自动化流量能否反复访问该端点?
  • 你是否验证过被拒绝的调用不会产生副作用,而不仅仅是留下一条日志记录?

模型并不拥有安全边界

身份验证告诉你谁在发起请求。授权告诉你他们通常被允许做什么。两者都不能告诉你,这个由模型根据它从某处读到的内容生成的特定操作,是否真的是任何人有意为之的。这项检查应当放在操作本身,由每次运行行为都一致的代码来执行,而不是从请求在上游看起来是否干净、或模型这次是否表现得合理来推断。

把边界放在副作用发生的地方。在此之前,其他一切都只是提议。