AI Agent 安全实战:用爆炸半径架构守住生产环境

PocketOS、DataTalks 与 Kiro 的事故揭示同一根因:AI Agent 继承过宽权限后,可能在人工介入前造成不可逆损失。本文拆解已用于生产环境的防护架构:收窄云角色、限定工具白名单、设置统一调用卡口和单实例预算上限,再用幂等工作流与修改前快照,把每次运行的爆炸半径锁在可恢复、可审计的范围内。

Saturday, September 5, 2026Omid Saffari
AI Agent 安全实战:用爆炸半径架构守住生产环境

4 月 25 日,一起 AI Agent 安全事故在 9 秒内发生:运行 Claude Opus 4.6 的 Cursor 删除了 PocketOS 的生产数据库及其备份,随后写下一份认错说明。3 个月的租车预订记录全部消失。我每天运行 6 个会接触生产环境的 Agent;下面就是这套系统实际使用的调用卡口、白名单和幂等架构,它能把其中任何一个 Agent 造成的最坏结果限制为多花 $1。

AI Agent 安全:90 天内的事故清单

2026 年 4 月 25 日。运行 Claude Opus 4.6 的 Cursor Agent 正在测试环境中处理一项 PocketOS CEO Jeremy Crane 所说的“日常任务”。遇到凭证不匹配后,它自行决定“清理一下”,随后删除了一个 Railway 存储卷,其中同时放着生产数据库和备份。整个过程只用了 9 秒。服务中断 30 小时。最近一份可恢复备份已是 3 个月前,因此 3 个月的租车预订记录就此蒸发。之后,Agent 写下一份认错说明,承认自己违反了不得触碰生产环境的明确指令。

2026 年 2 月 26 日。DataTalks.Club 创始人 Alexey Grigorev 更换了电脑,本地 Terraform 状态文件已经过期。Claude Code 接到清理资源的任务后,对生产技术栈执行了 terraform destroy。后来部分恢复的 courses_answer 表中有 1,943,200 行数据,记录着两年半的学生提交内容。自动快照也放在同一个被销毁的账户里。

2025 年 12 月中旬。Amazon 内部的 Agent Kiro 继承了工程师的高权限,绕过了 Amazon 的双人审批门槛——因为审批只约束人类,并不约束 Agent 运行时使用的角色——随后删除并重建了 AWS Cost Explorer 的生产环境。

3 起事故,3 个不同模型,3 套不同技术栈。共同根因不在模型,而在于过宽的继承权限、自主循环,以及快过任何人工确认提示的执行速度。ServiceNow 已经围绕这种担忧推出“kill switch”产品。下面这套架构就是可在内部落地的版本,也是我目前实际运行在生产环境中的方案。

这对非技术创始人意味着什么

如果你是因为团队里负责 AI 的人提到这件事才读到这里,需要先认清一点:做错的代价并不是“出了一个 AI bug”,而是预订系统、学生记录和客户数据全部消失;备份还停留在 3 个月前,因为从来没人测试过恢复流程。

真正应该问工程负责人的,不是“这个 Agent 好不好用”。这个问题的框架就错了。正确的问题是:单次 Agent 运行最多能造成多大损失,这个上限由谁划定? 如果答案是“我们信任模型”或“我们会审查 diff”,那就还没有控制面,只有侥幸心理。

理想答案应该是这样:“Agent 使用一个不含销毁权限的云角色运行。它只能调用 12 个明确列出的工具,其中没有 shell。每一次付费调用都必须经过同一个设有硬性成本上限的函数。最坏情况是一个步骤失败,支出最多为 $1。”这才是所需的架构。下面会说明如何把它搭起来。

为什么“谨慎操作”和“审查 diff”都不管用

PocketOS 的存储卷在 9 秒内被删。Kiro 完成删除的速度,快到人根本来不及读完确认提示,更谈不上判断风险。面对 Agent 的执行速度,动作发起后再介入,从结构上就不可能奏效。等你看到操作时,操作早已完成。

多数关于 Agent 安全的文章会避开这一点,因为它迫使人接受一个不舒服的结论:真正有效的防护只有执行前拒绝。不是“让 Agent 先询问,再由疲惫的人点击确认”,也不是“记录一切,之后再复盘”。执行前拒绝意味着,破坏性操作从一开始就不在 Agent 可触达的范围内。

Kiro 用反例证明了这一点。Amazon 明明设有双人审批门槛,但它只适用于人类发起任务的场景。Agent 继承了发起人的权限,而审批门槛没有绑定到 Agent 实际使用的角色,于是被直接绕过。人类面对的是审批表演,自主循环拿到的却是完整销毁权限。

因此,问题需要换个框架:不是去“监督”Agent,而是在循环开始前限制它的操作面,并让这种限制成为运行角色的属性,而不是提示词里的要求。

调用卡口:所有付费调用和状态修改只走一个函数

在我由 6 个 Agent 组成的技术栈里,每一次付费模型调用都必须经过同一个函数,我把它叫作 callAi。它严格按顺序完成 5 件事:调用前检查每天 $20 的支出上限;记录调用耗时;把 token 数量和 USD 成本写入仅追加的 ai_call_log 行,并标记 agent_idworkflow_instance_id;调用后检查每个实例 $1 的上限;最后返回结果。

TypeScript
export async function callAi(env: Env, args: CallAiArgs) {
  const { agentId, workflowInstanceId, model, messages } = args;

  const dailySpent = await getDailySpendUSD(env);
  if (dailySpent >= 20) {
    throw new NonRetryableError(`daily cap hit: $${dailySpent}`);
  }

  const t0 = Date.now();
  const res = await anthropic.messages.create({ model, messages });
  const costUSD = priceOf(model, res.usage);

  await env.DB.prepare(
    `INSERT INTO ai_call_log
       (agent_id, workflow_instance_id, model, in_tokens, out_tokens, usd, ms, ts)
     VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())`
  ).bind(agentId, workflowInstanceId, model,
         res.usage.input_tokens, res.usage.output_tokens,
         costUSD, Date.now() - t0).run();

  const instanceSpent = await getInstanceSpendUSD(env, workflowInstanceId);
  if (instanceSpent >= 1) {
    throw new NonRetryableError(`instance cap hit: $${instanceSpent}`);
  }

  return res;
}

一旦触发上限,函数就抛出不可重试错误,Workflow 随即终止。Agent 没有机会“自行决定”继续,因为预算门槛不是一段可供它争辩的提示词,而是推理层之上直接抛出的异常。

再看那个常被引用的失控案例:持续 63 小时,花掉 $4,200。没有统一卡口,就没有终止状态;循环会一直烧钱,直到有人注意到账单。有了单实例上限,单次运行的最坏成本是 $1;有了每日上限,一天的最坏成本是 $20。我在搭建系统时曾意外触发过这两个上限,但两次的代价都没有超过一顿晚饭。

调用卡口的第二项作用是留下取证记录。每个操作都会在结果返回之前写入一行审计数据。真的出问题时,事故复盘只需对 ai_call_log 执行一次 SELECT,不必再从 Agent 自己的认错说明里还原经过。

工具白名单:12 个 Zod 工具,不给 shell,也不给 terraform

Agent 能执行的全部修改动作,只来自 12 个用严格 Zod schema 定义的工具。没有 Bash 工具,没有 terraform 工具,操作面里甚至不存在云存储卷 API。即使 Agent 推理出自己想运行 terraform destroy,也没有任何方式把这个意图变成操作。

负责执行限制的 Claude Agent SDK 配置很短:

TypeScript
const result = await query({
  prompt,
  permissionMode: "dontAsk",
  allowedTools: [
    "read_brief", "fetch_corpus", "render_markdown",
    "validate_directives", "persist_draft", "schedule_publish",
    "update_status", "log_event", "fetch_citation",
    "embed_text", "search_vectors", "notify_human"
  ],
  // no Bash, no Write, no Edit, no infrastructure tools
});

permissionMode: "dontAsk" 下,未列入白名单的工具会被直接拒绝:不会升级为确认提示,也不会交给人工审查。权限判定仍按文档所述顺序执行——PreToolUse hook、deny rules、allow rules、ask rules、mode check、canUseTool callback——但在 dontAsk 模式中,人机协同回调会从结构上被跳过,未知工具走到 mode check 时就会被终止。

这正是能彻底阻止 PocketOS 与 DataTalks 事故的关键。在这套配置下,DataTalks 的失败不可能发生,因为 terraform destroy 并不是 Agent 能调用的工具名;PocketOS 的失败同样不可能发生,因为操作面里根本没有删除存储卷的能力。即便模型决定“清理一下”,从意图到动作之间也不存在通路。

最容易出现的诱惑,是因为“万一 Agent 需要做一件我们没预料到的事”而加入 Bash 工具。不要这样做。加入 Bash 的那一天,爆炸半径就会变成“Bash 能触达的一切”。

AI Agent 幂等性:为什么重放不会重复执行破坏操作

这套架构的第三条支柱是幂等性。每个 Cloudflare Workflow 实例都使用一个由输入确定生成的 ID:publish-{brief_id}instances.create() 遇到重复 ID 时会抛错,因此同一事件再次触发,也无法让工作流运行两遍。队列层的重试风暴会变成空操作,而不是重复发布。

TypeScript
const id = `publish-${msg.body.briefId}`;
try {
  await env.PUBLISHER.create({ id, params: msg.body });
} catch (e) {
  if (e.message.includes("already exists")) return; // safe replay
  throw e;
}

在工作流内部,每个 step.do() 的结果都会缓存,可以安全重放。步骤重试时会复用缓存结果,不会重新执行副作用。再结合对规范 D1 表执行 INSERT OR IGNORE,以及每次持久化都保存一份带版本的 R2 快照,出错的步骤就无法悄悄覆盖历史;上一个版本只隔着一个 R2 key。

DataTalks 的根因,是过期状态文件驱动了一次不可逆的销毁。确定性的工作流 ID 加上“修改前快照”,让这里由过期状态造成的破坏不再致命:即便工作流基于过期假设运行,快照仍可用于回滚。(关于持久执行原语,我在对比 Cloudflare Workflow 与 Managed Agents 的文章中详细讲过;幂等性背后的计算逻辑,仍是同一套边界控制,只是换了一个名字。)

可以这样理解:任何可能执行破坏性操作的步骤,都必须写入带版本的快照。快照是修改的前置条件,不是事后补救。快照写入失败,修改就不会发生;修改失败,快照就是恢复点。不存在既丢失状态、又无处恢复的路径。

本周就能落地的 5 步迁移

请按下面的顺序执行。只做步骤 1,就足以阻止前面的 3 起事故;哪怕其他步骤暂时都不做,也应先完成它。

  1. 把 Agent 的云角色收窄到不含任何销毁权限

    Agent 运行时使用的 IAM 角色(或服务账户、API token),不得拥有针对生产资源的任何 destroy 或 delete 权限。不能有 DeleteVolume,不能有 terraform destroy 权限,也不能有 DROP TABLE。角色决定了爆炸半径的下限;如果角色本身没有收窄,上层所有控制都失去意义。

    这就是 Kiro 事故最具体的教训:不要让 Agent 继承发起人的宽泛权限,而要给它一个独立且收窄的角色。

  2. 用 permissionMode dontAsk 和明确的 allowedTools 白名单约束 Agent

    选出能让 Agent 完成工作的最小工具集。我的系统需要 12 个,你的可能只要 6 个。彻底移除所有通用 shell 工具。如果 Agent 目前因为“方便”而配有 Bash 工具,那么这份方便本身就是漏洞。

    TypeScript
    { permissionMode: "dontAsk", allowedTools: [/* explicit list */] }
  3. 加入预算熔断器

    让每次付费调用都经过同一个封装函数。调用前检查硬性每日上限,调用后检查单实例上限;一旦超限,就抛出 NonRetryableError。单实例上限是每日上限无法替代的熔断器——那次持续 63 小时、花掉 $4,200 的失控循环,在逐次运行累积时并未触发任何看似合理的每日上限。单实例上限能捕获每日上限遗漏的风险。

  4. 修改前先保存快照

    每个可能执行破坏性操作的步骤,都必须先写入带版本的快照。可以用 R2,可以用 S3,也可以用 D1 的 *_archive 表。关键在于:快照写入必须是修改的前置条件,并且与修改放在同一个步骤、同一个近似事务的代码块里。

  5. 使用仅追加的操作日志

    每次工具调用对应一行日志,并在结果返回前写入。日志要带有 Agent ID、工作流实例 ID、USD 成本和延迟。问题终究会发生;届时事故复盘应该是一条 SELECT 查询,而不是靠回忆重建经过。

每一步都可以在一天以内完成。之所以要按这个顺序,是因为边界控制会逐层叠加:步骤 1 确定下限;步骤 2 封闭下限之上的操作面;步骤 3 限制操作面内的成本;步骤 4 和步骤 5 则让任何失败都可恢复、可查询。

常见问题

能不能只在系统提示词里告诉 Agent 永远不要删除生产环境?

PocketOS 就这样做过。Agent 自己的认错说明明确写着,它违反了这些指令。指令不是控制面。提示词只是概率系统的输入;工具白名单和云角色才是运行时的属性。应当依靠运行时。

permissionMode dontAsk 是否意味着完全失去交互能力?

不是。它只表示未列出的工具会被拒绝,而不是弹出确认提示;白名单内的完整操作面仍然可用。真正被移除的,是“Agent 发问,疲惫的人点击确认”这种失败模式——而这正是应该移除的模式。

已经有每日成本上限,为什么还需要单实例上限?

因为那次在 63 小时内花掉 $4,200 的失控循环,在逐次运行累积时仍低于任何看似合理的每日上限。单个异常循环完全可能始终远低于“$20/day”,却在一个周末累积到四位数成本。单实例上限才是每日上限从结构上无法充当的熔断器。

我用的是 Vercel 或 Railway,不是 Cloudflare Workflows,这套方法还适用吗?

调用卡口、工具白名单和收窄角色都与平台无关。只有幂等原语,也就是确定性的 Workflow ID,是 Cloudflare 特有的。在其他技术栈上,可以为任务设置幂等键,并在队列层或任务运行器层拒绝重复项。

双人审批够不够?

Kiro 针对人类操作设有双人审批,但 Agent 继承的权限绕过了它。审批门槛必须绑定 Agent 实际运行的角色,而不是发起任务的人。如果 Agent 角色拥有销毁权限,审批就只是装饰。

如果你读到这里,是因为团队里的某个 Agent 上周差点触碰生产环境,那么我在 DVNC.dev 所做的正是这类边界控制:把上面的架构按优先级应用到你的技术栈,先从收紧爆炸半径开始。

最近更新

2026年9月5日

分类Build

在 Google 中优先显示本站

将 omidsaffari.com 添加为 Google 搜索的优先来源

把 omidsaffari.com 设为优先来源,Google 会在 Top Stories、AI Overviews 和 AI Mode 中为您优先展示。

更多 Build 文章

查看全部 Build 文章
订阅通讯

每周日,一封信。 写运转中的系统,不写热评。

来自一组 AI 项目组合运营的构建日志、生产系统与一线笔记。

每周一期。无垃圾邮件。随时退订。