# 为安全 apply 设计 AI 代理基础设施权限

AI 代理不应获得一项长期有效的权限，让它可以“应用基础设施变更”。它们需要的是范围明确且会过期的授权，只能将一项经过审阅的变更应用到一个已确认的目标账户，并且必须配有操作员已经阅读并接受的恢复流程。

我见过一些团队把 Terraform 计划误当成安全控制，把审批按钮误当成决策。单独来看，两者都不够。计划可以针对另一个账户重新生成。审批可能在提交发生变化后仍然有效。回滚说明可能只写着“恢复”，但实际变更却删除了数据，或改变了一个已经不存在的依赖。

更可靠的边界要求更高：代理准备证据，人来审阅证据；如果证据已经不能准确描述要执行的操作，执行路径就必须拒绝继续。直到第一次出现这种情况之前，这听起来可能有些繁琐。比如，助手继承了错误的云配置文件，读到过期的状态快照，却把一个完全有效的命令指向了生产环境。

## 权限必须绑定操作，而不是职位名称

AI 代理基础设施权限应授权一项具体变更，而不是“部署”“Terraform”或“生产运维”之类的类别。宽泛的类别看起来很方便，因为人们习惯按角色思考。但云 API 执行的是请求，而请求有明确的目标、参数、身份和后果。

一项有用的 apply 授权至少应绑定以下事实：

- 用于生成计划的不可变源版本和配置目录。
- 经过审阅的计划工件，或其 SHA-256 摘要。
- 执行凭据所报告的目标云账户或租户身份。
- 执行身份、角色、订阅、项目，以及在适用时的允许区域。
- 有效期和恢复引用，告诉操作员 apply 必须停止或撤销时会发生什么。

区分“计算”变更的权限和“执行”变更的权限很重要。可以让代理检查代码仓库、调用只读 API、运行验证并提出计划。但不要默默把这些活动当成它可以修改生产环境的证据。发现权限通常会暴露运营细节。修改权限则会改变账单、可用性，有时还会改变事故发生后可获得的证据。

名为 `InfrastructureDeployer` 的角色不是审批对象，而只是实现细节。如果任何进程拿到它后，就能在任何账户随时执行 apply，那么审批流程就依赖于人们记得正确使用它。代理的速度足以把这种记忆测试变成事故。

## 经过审阅的计划必须是实际执行的那个工件

经过审阅的计划只有在 apply 命令使用同一个计划时才有价值。有人批准输出后再次运行 `terraform plan`，就会生成新的工件，即使文件看起来没有变化。

Terraform 的命令文档清楚地说明了这一点。`terraform plan -out=FILE` 会写入一个计划文件，供 `terraform apply FILE` 使用；不带保存计划的 `terraform apply` 会在请求确认前创建新计划。第二种形式适合交互式人工会话，却不适合声称“人已经审阅了拟议变更”的代理工作流。

使用保存的计划，并将其渲染为 JSON 供审阅。最小的 Shell 流程如下：

```sh
set -euo pipefail

git rev-parse HEAD > evidence/source-revision.txt
terraform init -lockfile=readonly
terraform plan -out=evidence/change.tfplan
terraform show -json evidence/change.tfplan > evidence/change.json
shasum -a 256 evidence/change.tfplan > evidence/change.tfplan.sha256
terraform providers lock -platform=darwin_arm64
```

输出摘要的形式很简单：

```text
f1f5f2...a94c  evidence/change.tfplan
```

审批记录应复制完整摘要，而不只是附上一张终端截图。apply 运行器随后在执行前验证它：

```sh
shasum -a 256 -c evidence/change.tfplan.sha256
terraform apply -input=false evidence/change.tfplan
```

这样可以避免一种常见故障：代理打开拉取请求，生成计划，收到审批评论，然后获取最新的 main 分支并运行全新的计划。在这些操作之间，其他同事可能已经合并了不同的变更。后面的计划可能增加删除操作、切换镜像版本，或指向另一个提供商别名。审阅者批准的是昨天提出的资源图，而不是运行器此刻看到的内容。

Terraform 二进制计划可能包含敏感值。不要把它粘贴到工单或聊天中。根据提供商的模式和具体值，JSON 表示也可能暴露敏感材料，因此只有在了解审阅流程的前提下进行脱敏。脱敏不能删除资源地址、操作、目标身份或依赖变更。这些正是审批人需要了解的事实。

保存计划的模式在执行环境也固定下来时最可靠。使用生成计划时相同的提供商锁定文件、Terraform 版本、变量、后端配置和工作区选择。如果其中任何输入发生变化，就丢弃计划并生成新的审阅包。试图挽救旧审批，会把合理的控制变成表演。

## 命名目标必须来自凭据，而不是标签

名为 `prod` 的目标几乎不能证明什么。代码仓库会复制目录名称，工作区会漂移，环境变量会留在 Shell 中。代理可能忠实地执行名为 production 的配置，却实际上已认证到沙盒，甚至因为继承的配置文件而选中了生产账户。

要求执行身份在规划前，以及 apply 前立即再次询问云控制平面确认自己的身份。将结果保存到证据包中，并与已批准的目标进行比较。

对于基于 AWS 的操作，检查可以简单到这样：

```sh
aws sts get-caller-identity --output json > evidence/caller-identity.json
cat evidence/caller-identity.json
```

结果会标识账户和主体：

```json
{
  "UserId": "AROAXXXXX:apply-run",
  "Account": "123456789012",
  "Arn": "arn:aws:sts::123456789012:assumed-role/InfraApply/apply-run"
}
```

对于 Google Cloud，分别记录活动账户和项目。对于 Azure，从凭据上下文中记录订阅 ID 和租户 ID。不要依赖云账户显示名称，因为人们可以复制这些名称。数字标识符或全局唯一标识符读起来可能不如名称方便，但执行时安全得多。

apply 前的检查必须使用与 apply 相同的凭据路径。这听起来理所当然，但包装脚本经常违反这一点。规划脚本可能使用短期的临时角色，而后续命令却继承了开发人员的默认配置文件。调用远程运行器的代理可能在本地规划，却在从未检查过的服务身份下远程 apply。

在运行器中加入机器可检查的目标断言。下面的示例会拒绝意外的 AWS 账户：

```sh
expected_account="123456789012"
actual_account="$(aws sts get-caller-identity --query Account --output text)"

if [ "$actual_account" != "$expected_account" ]; then
  printf 'refusing apply: expected account %s, got %s\n' \
    "$expected_account" "$actual_account" >&2
  exit 1
fi
```

这项检查不能取代人工审阅，但能在任何 API 调用修改基础设施之前，拦截一整类错误。在提供商支持的情况下，再配合声明允许账户或订阅的提供商配置。提供商级断言和运行器级断言彼此独立地失败，这正是你需要的效果。

## 状态漂移会让旧审批变得不安全

计划描述的是相对于某个特定观测状态的目标变更。它并不保证一小时后那个状态仍然存在。

其他工程师可能已经部署。自动扩缩容器可能增加或删除了资源。云服务可能轮换了附加项、替换了节点，或完成了异步操作。Terraform 刷新状态时通常会发现其中一部分变化，但如果出现实质差异，安全做法不是因为之前的计划看起来很小就继续执行。应生成新的工件，并审阅新的差异。

有效期可以处理其中很大一部分问题。审批的有效时间应足够短，让人仍然记得自己为什么批准它。合适的时长取决于发布流程，但默认不应覆盖整个工作日，而应覆盖一段明确的执行窗口。窗口关闭后，强制生成新计划，再次验证目标并要求新的审批。

还需要定义失效规则。审阅后发生以下任何变化，都应拒绝计划：

- 源版本、变量集、模块引用或提供商锁定文件发生变化。
- 工作区、后端、账户、订阅、租户、项目或区域与已批准证据不同。
- 计划摘要与审批记录不匹配。
- 相关状态锁、刷新或前置条件报告了会改变拟议操作的冲突。

不要用“拉取请求中没有变化”替代这项规则。外部数据源、提供商默认值、当前状态和凭据都在差异之外。基础设施工作包含太多输入，不能让只针对源代码的审批承担全部决策。

有些团队试图通过允许代理自动重新规划，直到计划变得干净，来解决漂移问题。这个建议很受欢迎，因为它减少了排队时间。但对于重要账户来说是错误的。自动重新规划可能把经过审阅的更新变成未经审阅的替代方案。如果愿意，可以允许代理为只读预览自动重新规划，但在实际修改前必须再次进行人工审阅。

## 回滚必须描述可恢复的状态

清晰的回滚路径会说明操作员如何让服务恢复到可接受状态、谁可以执行、会带来哪些数据风险，以及什么时候应该停止。“运行 Terraform destroy”和“恢复提交”通常达不到这个标准。

基础设施变更属于不同的恢复类别。把它们一视同仁会制造虚假的信心。

可逆的配置变更，例如安全组规则或负载均衡器权重，可能可以从已知良好的版本直接反向应用。替换实例组可能需要在回退前检查容量。数据库迁移一旦写入数据可能无法逆转，因此恢复路径可能是前向迁移、从经过验证的备份恢复，或使用停止新写入的功能开关。

用运维语言写出回滚路径。一份有用的记录应回答以下问题：

1. 哪个可观测条件会告诉我们应该回滚，例如健康检查失败、错误率上升或冒烟测试失败？
2. 哪个确切版本、参数集或命令可以让服务回到已知良好的配置？
3. 执行前必须具备什么前置条件，例如备份恢复点、备用容量或经过批准的维护窗口？
4. 如果代理会话已经结束，或原审批人无法联系，谁有权采取行动？
5. 哪些内容无法自动恢复，包括记录、密钥、公共地址或云平台上的手动变更？

恢复路径必须在事故发生前测试，而不是在事故中写下一句乐观的话。如果团队认为某项变更可逆，就在有代表性的环境中执行反向操作，并记录成功所需的条件。提供商可能会保留已删除的资源名称，配额可能阻止重新创建，依赖服务也可能缓存旧端点。这些细节通常会在变更已经承受压力时暴露出来。

对于破坏性变更，应要求单独决策。计划审阅后删除资源，与更新资源并不等价。代理应以无法被数百项无害更新淹没的形式，展示删除地址、替换操作、保留设置和备份证据。如果变更涉及数据库、对象存储、身份绑定、网络边界或 DNS 区域，就应坚持让恢复负责人阅读计划。

## apply 运行器应拒绝模糊信息

执行修改的运行器必须自己强制执行审批事实。一个可以接收聊天消息“继续吧”的机器人，没有可靠方法区分已确认的计划和随口指令。

使用结构化审批记录。它可以存放在签名的部署系统、受保护的代码仓库记录或其他受控存储中。存储方式不如字段和验证机制重要。下面的示例 JSON 展示了基本结构：

```json
{
  "change_id": "infra-2025-041",
  "source_revision": "4ad7d2f",
  "plan_sha256": "f1f5f2...a94c",
  "target": {
    "cloud": "aws",
    "account_id": "123456789012",
    "region": "us-east-1",
    "workspace": "production"
  },
  "approved_by": "operator-id",
  "expires_at": "2025-04-18T15:30:00Z",
  "rollback_ref": "runbook: payments-api capacity revert"
}
```

代理可以组装这份请求，但不应填写 `approved_by` 或延长 `expires_at`。审批服务应在人阅读渲染后的变更后，再写入这些事实。apply 运行器读取记录，重新计算计划摘要，检查源版本和目标身份，然后在发送第一个写请求前将授权标记为已消费。

消费审批很重要。没有这一步，代理可能在环境已经发生变化后，再次重试之前批准的操作。失败的 apply 也需要明确状态。不要因为运行器发出了命令，就把它标记为完成。应记录 Terraform 是否返回成功、云 API 操作是否仍在等待，以及操作员是否接受了最终状态。

让代理的写入面保持狭窄。它可能需要通过 HTTP 调用部署 API，或通过 SSH 访问受控运行器，但绝不应在上下文中接收可重复使用的云密钥。Sallyport 会在执行请求的操作时，将 API 和 SSH 凭据保存在加密保险库中，并把结果返回给代理。这有助于防止凭据被复制，但不会让模糊的 apply 请求自动变得安全。

## 逐次调用审批应放在危险边界周围

逐次调用审批在基础设施工作中有其作用，但不能取代工件审阅。如果代理每次调用云 API 都请求许可，操作员会在不了解整体结果的情况下批准一长串请求。这就是审批疲劳，也会训练人们点击通过唯一的控制手段。

把人工介入放在真正需要决策的位置：批准绑定了目标的计划，然后允许有范围限制的 apply 运行。对于影响范围异常大的操作，再保留单独确认，例如密钥轮换、删除受保护对象、紧急访问，或执行运行器约定之外的命令。

Sallyport 的逐会话授权可以确认某个特定代理进程在当前运行期间可以使用操作通道，而逐次调用密钥则可以要求对敏感凭据进行单独确认。这构成了清晰的凭据边界。不过，部署工作流仍需定义哪个调用算作已批准的 apply，以及哪些凭据值得逐次调用的额外摩擦。

操作员应看到足够的证据，无需阅读原始提供商流量就能做出决策。展示按操作分组的资源地址，并将替换和删除单独列出，同时显示目标身份、源版本、摘要、有效期和回滚引用。然后为需要深入了解的人提供完整计划。把破坏性变更藏在一百项更新中，是展示失败，不是操作员失败。

## 失败的 apply 与成功的 apply 需要不同决策

Terraform 可能在已经修改了多个资源后返回失败。云控制平面也可能接受请求，并在之后完成操作。把任何非零退出状态都视为“什么也没发生”，是自动化运维中更危险的习惯之一。

apply 失败时，先冻结自动重试。捕获运行器输出、状态锁信息、目标身份，以及已经完成的资源子集。然后检查实际环境，再决定如何处理。盲目重试可能加重部分失败，而立即回滚又可能删除提供商仍在创建的中间资源。

使用以下决策顺序：

1. 确认当前云身份，并收集失败操作中每个资源的实际状态。
2. 判断目标状态是可以安全完成、可以安全反向恢复，还是需要修复计划。
3. 根据当前状态生成新的计划，并让操作员把它作为新的变更进行审阅。
4. 将事故决策记录在原审批旁边，包括 Terraform 无法表示的任何手动操作。

这正是审计记录发挥作用的地方。将源版本、计划摘要、审批身份、目标证据、命令记录、最终状态和后续决策放在一起保存。防篡改的事件记录比散落在聊天线程中的截图更好，因为响应人员需要确认事件顺序，而不是凭记忆重建意图。

不要承诺自动化会撤销每一次失败的 apply。有些变更需要了解服务依赖、数据持久性和客户影响的人来处理。代理可以快速收集证据，但不应因为流水线期待绿色结果，就臆造恢复操作。

## 让第一次生产发布刻意保持平淡

第一次由代理参与的生产 apply，应当只修改小范围、可逆且可观测的内容。选择已有经过测试恢复流程的已知配置调整，不要一开始就做迁移、网络重构，或涉及多个使用方的密钥轮换。

在正常条件下运行完整流程：生成证据包，验证确切账户，审阅渲染后的计划，批准摘要，应用保存的工件，检查结果，并消费授权。然后执行一次受控故障演练。让审批过期、修改源版本，或把运行器指向未批准的账户，确认运行器会拒绝操作。

这些拒绝测试比一次成功的正常部署更重要。账户、计划和状态碰巧一致时，任何工具看起来都很有纪律。控制真正得到证明的时刻，是匆忙的操作员、过期的工件或困惑的代理要求它做错事，而它能够停下来。

不要因为第一次发布感觉缓慢，就扩大权限模型。先衡量审阅时间花在哪里。如果人们总是在比较账户标识符，就改善证据展示。如果计划包含太多无关变更，就修正模块所有权或状态边界。如果回滚说明不够可靠，就要求服务团队编写并测试它们。宽泛的长期访问权限不是笨拙发布流程的解决方案，只会把弱点藏起来，直到代理触及它。
