# 为存在风险的自主编程代理设计 API 作用域

自主编程代理应该拿到一份针对明确工作的凭据，而不是一份恰好能让错误消失的凭据。难点不在于找到一个叫作 `write` 的作用域，而在于证明令牌能完成一项任务，同时会拒绝那些相邻但不属于该任务的操作。

我见过团队因为代理需要“先跑起来”，就先使用个人令牌。几周后，这个令牌已经可以读取开发者曾经接触过的所有仓库、修改部署设置，还能发起原任务完全不需要的破坏性请求。风险不是代理造成的，而是松散的权限边界造成的。

## 任务比角色更具体

代理任务描述的是一个结果，以及一组有边界的状态变化。角色描述的则是某个人或某项服务的宽泛职责。根据角色授予访问权限，几乎总会比任务实际需要的更多。

比如：“更新 payments 服务中的一个依赖，运行测试套件，然后创建拉取请求。”代理可能需要读取一个仓库、创建分支、向该分支推送提交并创建拉取请求。如果测试服务通过 API 提供构建日志，它还可能需要读取这些日志。但它不需要管理组织、修改受保护分支规则、轮换部署凭据，也不需要合并自己的工作。

把任务契约写成针对明确资源的动词。不要写“仓库写入权限”，而要写清代理实际可以做什么：

- 读取 `payments-service` 中的源代码、问题和现有拉取请求。
- 创建和更新名称以 `agent/` 开头的分支。
- 从该分支向指定的基础分支创建一个拉取请求。
- 读取该拉取请求启动的工作流状态和日志。
- 发布一条包含测试结果的评论。

这不是官僚流程。这样的清单会暴露尚未决定的问题。代理能关闭问题吗？能修改别人的拉取请求吗？可以重新运行成本高昂的工作流吗？需要从私有注册表获取软件包吗？每个动词要么获得相应权限，要么被删掉。

任务契约还要区分必要的副作用和方便的副作用。代理创建拉取请求后，可能希望更新问题标签。这也许有用，但并不是完成依赖更新所必需的。先把它排除在第一组权限之外。只有在有人接受这一影响并测试边界后，再考虑加入。

即使由同一个代理进程执行，也要把定期任务视为不同任务。每晚的依赖检查、发布推广和生产环境回滚，后果各不相同。一个积累了大量权限的身份会让三者都更难审查，也无法干净地撤销。

## 作用域名称不是权限边界

作用域字符串只是授权的输入，不是 API 调用安全的证明。服务商会用“作用域”指代几种不同机制：OAuth 字符串、仓库权限、项目角色、安装授权，以及限制到资源列表的令牌。它们不能互相替代。

OAuth 2.0 RFC 6749 将 scope 定义为空格分隔的字符串集合，用于限制访问令牌的访问范围。每个字符串的具体含义由授权服务器决定。这样的灵活性对服务商很有用，但也意味着在查看服务商的端点文档并测试令牌之前，`repo:write`、`projects.write` 和 `api` 几乎说明不了问题。

RFC 8707 增加了资源指示器。客户端可以请求面向某个特定受保护资源的令牌，而不是把授权服务器后面的所有端点都当成一个目标。发行方支持这一机制时，它会很有帮助。但如果服务商把一个宽泛作用域映射到某个资源中的所有项目或所有破坏性端点，它也无法修复这个问题。

在设计记录中分开保留以下三层：

| 层 | 它回答的问题 | 混淆后的后果 |
|---|---|---|
| 令牌作用域 | 发行方把哪些权限标签放进了这个令牌？ | 你以为一个好听的标签代表狭窄操作。 |
| 资源授权 | 这个身份能够访问哪些仓库、项目、账户或环境？ | 令牌可以操作相邻资源。 |
| 端点规则 | API 会接受这次请求的方法和路径吗？ | 写入权限允许删除或管理操作。 |

常见的糟糕建议是“使用只读加写入”。它很受欢迎，因为可以塞进配置指南，而且第一次通常就能用。问题在于，write 往往覆盖多个互不相关的动词。创建拉取请求、删除仓库、修改 webhook 和修改访问控制，可能都由同一个宽泛授权控制。

如果服务商只提供宽泛作用域，不要靠精心命名来假装实现了最小权限。应改为限制资源层。创建一个专用仓库、项目、环境或服务账户，只授予它访问目标的权限。如果代理只需要执行一项生产操作，就为该操作提供独立身份，并要求明确审批。服务商的模型仍然粗糙，但这份凭据能够触及的范围更小。

## 发放令牌前先建立端点清单

端点清单可以把模糊请求变成可审查的权限设计。它记录代理允许发起的每次调用、调用原因、可以触及的资源，以及启用调用的确切权限。

从操作顺序开始，而不是从服务商的权限页面开始。一个创建拉取请求的代理，往往需要比人们想象中更多的调用：读取基础版本、创建引用、创建或更新文件、获取工作流状态，然后提交拉取请求。权限页面很少会告诉你在选定工作流中哪一步是必需的。

可以使用下面这样的清单。将示例路径替换为服务商实际记录的路径。

```yaml
task: update dependency and open pull request
resource: org/payments-service
calls:
  - method: GET
    path: /repos/org/payments-service/contents/package-lock.json
    purpose: read current dependency lockfile
    permission: contents:read

  - method: POST
    path: /repos/org/payments-service/git/refs
    constraint: "ref starts with refs/heads/agent/"
    purpose: create working branch
    permission: contents:write

  - method: PUT
    path: /repos/org/payments-service/contents/package-lock.json
    constraint: "branch starts with agent/"
    purpose: commit updated lockfile
    permission: contents:write

  - method: POST
    path: /repos/org/payments-service/pulls
    constraint: "base is main; head starts with agent/"
    purpose: request review
    permission: pull_requests:write

forbidden_calls:
  - DELETE /repos/org/payments-service
  - PATCH /repos/org/payments-service/branches/main/protection
  - POST /repos/org/organization-hooks
  - GET /repos/org/another-service/contents/secrets.yml
```

`constraint` 字段很重要，因为端点权限常常比任务权限更粗。API 可能允许创建分支，却没有原生机制将其限制为 `agent/` 前缀。把这个缺口记下来。你可能需要中间操作服务、独立仓库或审查网关，因为作用域无法强制执行你想要的分支规则。

不要依赖代理提示来维持这些限制。提示可以说明预期的分支前缀，却不能拒绝发往 `main` 的请求。强制执行的位置必须是 API 服务商、目标资源设置，或在发送请求前检查请求的操作网关。

读取调用和写入调用同样需要认真记录。读取部署密钥、客户导出数据、安全公告或第二个仓库，造成的暴露可能比错误提交更严重。权限审查往往只关注写入，因为写入更容易看见。但代理的上下文窗口也会让宽泛读取变得危险。

## 将发现访问和修改访问分开

发现访问和修改访问通常应该使用不同凭据，因为代理更常需要广泛上下文，却不常需要广泛地改变状态的权限。

规划代理可能需要搜索代码、检查问题、查看构建输出，并在多个仓库之间比较版本。修补代理可能只需要向一个仓库中的一个分支写入。如果两项工作共享令牌，修补代理就会继承规划器广泛的读取范围，规划器也会继承它根本不需要的写入能力。

服务商允许时，将工作拆成几个阶段。发现阶段输出一份有边界的计划或补丁提案。第二个进程接收这个产物，并使用更窄的凭据执行请求的修改。如果修改涉及受保护区域，可以让人审查这次交接。

这种分离能捕捉到提示无法解决的实际故障。假设规划器在组织中搜索某个软件包的引用，并找到一个包含部署说明的旧内部仓库。如果同一个令牌能向它读到的每个结果推送内容，后续一次错误的工具调用就可能修改错误仓库。即使模型完全理解任务，也可能选错标识符。将写入者限制在目标仓库后，这种错误就会变成被拒绝的请求。

不要为了制造更多令牌而拆分令牌。只有在允许访问的资源集合或动词不同的时候才拆分。一个读取令牌可以支持连贯的调查，一个写入令牌可以支持目标内部紧密相关的编辑。关键是让每个令牌都能用足够简短的答案回答“这个进程能做什么”，让工程师无需猜测就能验证。

对于源代码管理，只要服务商允许，就应将分支写入权限和合并权限分开。分支是提议中的变更，合并会改变共享基线，还可能触发部署、发布或下游自动化。代理可以创建有价值的拉取请求，却不必获得合并权限。

## 先限制资源，再细化作用域

组织级凭据上的狭窄作用域，往往不如隔离且可丢弃目标上的宽泛作用域安全。作用域控制动词，资源边界控制这些动词落在哪里。两者都需要，但资源边界通常更能让错误变得可控。

给自主代理使用服务身份，而不是个人令牌。个人访问令牌往往继承用户过去的成员资格、临时管理员权限，以及设置时没人记得的项目访问权。之后撤销其中一个令牌还可能打断无关工作，于是团队会拖延撤销。临时例外就这样变成永久访问。

专用身份应从零访问权限开始，只获得端点清单中列出的资源授权。代理处理一个仓库，就授予它这个仓库，而不是整个组织。代理更新暂存部署，就授予它暂存环境，而不是所有环境。代理为某个客户账户写入记录，就授予它该账户，而不是全局 API 凭据。

使用独立的非生产目标来测试权限。在生产环境实际写入，可以证明令牌能用，却不能证明它受到了恰当限制。测试仓库或项目可以让你测试创建、更新、失败、撤销和审计行为，而不会在实时系统中留下清理工作。

资源隔离也能补偿不理想的作用域模型。有些服务只发行带一个 `api` 作用域的令牌，完全没有端点粒度。你仍然可以创建一个只包含代理允许操作资源的专用项目，拒绝它管理组织，并为每个环境使用不同身份。这不如细粒度 API 优雅，但远好于把全能令牌交给会动态构造请求的进程。

不要因为代理修改的代码最终会进入生产环境，就直接给它生产访问权。发布系统应通过经过批准且单独授权的路径负责这次转换。如果任务确实包括生产操作，就为该操作另写一份契约，明确目标环境、允许的方法、可接受的参数、回滚行为和批准人。

## 把成功和拒绝当作同一份契约来测试

权限集合只有在证明两件事后才算完整：代理能够完成分配的工作，且附近那些未分配的操作会失败。只测试成功路径只能证明方便使用，不能证明访问受到了控制。

每次修改权限时，都使用干净的测试身份。现有凭据可能有缓存授权、继承角色或第二条认证路径，让测试因为错误原因看起来成功。运行前记录令牌主体、目标资源、发行的作用域和过期时间。

实际测试流程可以是：

1. 创建一个可丢弃的目标资源，以及一个带有拟议授权的凭据。
2. 让代理或确定性的请求夹具执行清单中的每个允许调用。
3. 验证预期状态，例如分支、拉取请求、评论或更新后的记录。
4. 使用同一凭据发送每个禁止调用，并期待被拒绝。
5. 删除凭据或撤销会话，然后重新执行此前允许的调用，并期待被拒绝。

除了代理运行，也要使用直接请求。直接请求可以排除工具选择的不确定性，显示服务商本身是否在执行边界。下面的 shell 夹具展示了测试形式。它假设 API 返回 JSON，并使用 `403` 表示身份已认证但没有所需权限。

```sh
base="https://api.example.internal"
auth="Authorization: Bearer $AGENT_TOKEN"

curl -sS -o allowed.json -w "%{http_code}\n" \
  -H "$auth" \
  -X POST "$base/repos/acme/payments-service/pulls" \
  -H "Content-Type: application/json" \
  -d '{"head":"agent/dependency-bump","base":"main","title":"Update parser"}'
# Expected output: 201

curl -sS -o denied.json -w "%{http_code}\n" \
  -H "$auth" \
  -X DELETE "$base/repos/acme/payments-service"
# Expected output: 403

cat denied.json
# Expected shape: {"message":"Resource not accessible by integration"}
```

不要只断言状态码。对于允许的操作，要检查最终状态。有些 API 接受请求后异步处理，或者返回成功却忽略代理依赖的字段。对于拒绝，要区分 `401` 和 `403`。`401` 可能意味着测试凭据格式错误或已过期。认证成功后返回 `403`，更能说明授权阻止了调用。服务商的语义各不相同，应在测试夹具中记录清楚。

每个危险权限边界都要保留负向测试。代理可以创建部署，就测试它不能推广部署。代理可以评论问题，就测试它不能修改标签或负责人。代理可以写入暂存密钥值，就在 API 支持只写不读时测试它不能读回该值。这些测试可以防止后续作用域修改悄悄扩大访问范围。

## 失败的 403 应该改变任务或授权

`403 Forbidden` 响应是关于契约的证据。它应该触发决策，而不是条件反射式地申请服务商最宽泛的权限。

我反复见过这种失败模式。代理创建分支并提交修复，然后在创建拉取请求时被拒绝。有人发现，服务商的拉取请求权限还允许撤销审查或编辑更广泛的讨论内容。因为代理需要完成任务，他们就授予了这项权限。几天后，同一个代理开始“清理”过期拉取请求，并修改任务范围之外的工作。

第一次拒绝其实提出了一个设计问题：创建拉取请求是否确实需要那种更宽的能力，团队能否接受它带来的副作用？诚实的答案有几种：

- 在测试完整端点范围并记录接受的风险后授予权限。
- 修改任务，让代理准备分支，由人来创建拉取请求。
- 使用另一个服务商身份或资源，让宽泛授权只触及目标仓库。
- 在服务商 API 前放置一个狭窄的操作服务，只接受资源和分支都固定的拉取请求创建请求。

错误的答案是添加每个能把红色响应变成绿色响应的作用域。这样只是把授权错误变成了延迟发生的事故。

请求体也值得审查。许多 API 权限模型会授权端点，却不区分安全值和有害值。`POST /deployments` 可能在同一权限下同时接受 `staging` 和 `production`。`PATCH /projects/{id}` 可能既允许无害的描述更新，也允许危险的可见性修改。如果服务商无法拆分这些操作，边界就必须放在端点之上。要求人工审批，使用专用目标，或者提供目的明确的操作，而不是开放原始 API 访问。

把被拒绝的调用及其新增或拒绝理由记录进清单。六个月后，这份记录可以解释为什么代理能创建分支，却不能重命名仓库。没有它，有人会在匆忙修复时认为边界武断并扩大权限。

## 审批网关可以拦截剩余的高后果调用

狭窄的服务商权限可以减少代理能够尝试的操作。对于仍然值得人来决定的允许操作，审批网关可以提供帮助，例如对外付款、在重要主机上执行 SSH 命令，或写入生产服务。

不要把审批当作授予代理宽泛凭据的理由。一次点击确认也许能阻止明显的错误请求，但当代理需要多次常规调用才能完成任务时，人们很容易快速批准重复的卡片。权限边界必须在审批卡出现之前，就拒绝整类操作。

在人类上下文会改变决定的地方使用审批。部署在技术上可能被授权，但在事故期间并不合适。删除分支可能被允许，但如果另一位工程师正在使用它，这个操作就不对。审批提示应在人员可以判断的时刻，展示实际目标和请求的操作。

Sallyport 将 API 和 SSH 凭据保存在加密的 macOS 保险库中，并在不向代理暴露密钥的情况下执行请求的操作。它的会话授权和逐凭据审批控制，可以让人参与那些值得人工判断的调用，但服务商一侧的作用域设计仍然决定已批准凭据能够触及什么。

审计记录要能回答两个不同的问题：哪个代理进程获得了操作许可，以及它实际发起了哪些 API 调用。这是两类记录。进程审批只能证明人允许那次运行使用凭据，不能说明运行是创建了拉取请求、修改了环境变量，还是尝试了一次失败的删除。权限变更和事故之后，都要同时审查这两类记录。

## 作用域审查需要触发条件，而不是日历承诺

当工程事件触发权限审查时，审查才真正有效。模糊的季度提醒往往只能找到那些已经没人记得用途的旧令牌。应将审查绑定到任务变化、新端点、资源扩展、服务商权限变更和代理工作流修改。

将端点清单和调用代理的代码放在一起。拉取请求如果修改了代理工具说明或增加了 API 调用，就要求同时更新清单以及正向和负向测试。这样，权限决定就紧挨着需要该权限的行为。

提前审查撤销流程。删除一个测试凭据，确认此前允许的请求会失败。禁用服务身份，确认运行中的代理无法通过缓存会话继续工作。检查服务商是否发行了刷新令牌或重复凭据，让相同访问仍然有效。团队常常在事故期间才发现这些路径，而那时答案已经没有那么有用了。

留意小改动中的权限蔓延。读取工作流日志的请求可能变成重新运行任务的权限。更新一个问题的请求可能变成组织级问题管理权限。访问一个环境的请求可能变成“以防万一”的生产回退权限。每次新增调用都要经受同一个问题：没有它，代理能完成声明的任务吗？

如果答案是否定的，就添加能启用该调用的最小授权，并为它最近的有害邻居加入拒绝测试。如果答案是肯定的，就不要添加。这样的纪律会让代理短期内的失败更加明显，也能防止错误指令、混乱的模型或被入侵的进程继承没人打算授予的权限。
