# 设计代理能够安全使用的 OpenAPI 操作

OpenAPI 文档可以告诉代理如何调用一个端点。但它本身无法告诉代理一次调用意味着什么、什么时候必须由人介入，或在结果不明确的失败后应该如何行动。如果把每个有文档说明的操作都当作代理操作，工具在演示中看似完整，日常使用却可能变得危险。

一个实用的操作比端点更小。它有明确的用途，有代理能够说明理由的输入，有代理可以据此行动的结果，有与影响相匹配的审批决定，还有清楚的失败处理方案。在把操作接入代理前，先完成这些设计。如果等到第一次重复扣款、误改生产环境或令牌泄露后再补救，代价会非常惨痛。

## 操作还不是代理操作

HTTP 端点、OpenAPI 操作和代理操作回答的是不同问题。人们容易把它们混为一谈，因为 OpenAPI 操作提供了一个方便的起点，但这些区别决定了自动化是否容易理解。

端点是 `/v1/deployments` 这样的地址。操作增加了 HTTP 方法，因此 `POST /v1/deployments` 与 `GET /v1/deployments` 不同。代理操作还要增加人与系统之间的契约：它要追求什么目标，接受哪些参数，可能产生什么影响，什么证据算作成功，以及谁必须同意。

OpenAPI Specification 定义了 Operation Object，其中包括 `operationId`、`parameters`、`requestBody`、`responses` 和 `security` 等字段。应把这些字段当作判断依据，而不是自动发布清单。一个模式写得很完整的操作，如果在描述中用无害的名称掩盖了生产环境影响，仍然可能是糟糕的代理操作。

看看下面两个操作：

```text
GET  /v1/projects/{project_id}/builds/{build_id}
POST /v1/projects/{project_id}/builds/{build_id}/promote
```

第一个操作获取记录。第二个可能改变流量、发布构件或修改发布渠道。路由只能暗示这种差异，操作设计必须直接说清楚。

我见过一些团队因为 API 已经有一份整洁的 OpenAPI 文件，就开放一个通用的 `request` 工具。代理随后可以任意拼接路径、查询字符串和请求体。这不是操作目录，而是针对业务 API 的远程代码执行，只是标点更漂亮。

只有在能够用下面的句式写出一句话后，才应开放一个操作：「此操作会对某个范围明确的对象执行某项具体操作，并返回能证明结果状态的证据。」如果必须使用「管理」「处理」或「应对」之类含糊的动词才能写出来，说明操作仍然过于宽泛。

## 先考虑影响，而不是请求模式

审批应该根据调用的后果决定，而不是根据 HTTP 方法或 JSON 请求体看起来是否简单。一个很小的 `POST` 可能产生不可撤销的义务。一个冗长的 `GET` 也可能泄露私密数据。`DELETE` 也许只会删除可以丢弃的草稿，而 `PATCH` 可能撤销其他所有人的访问权限。

在检查字段之前，先用负责系统的人能够理解的语言描述影响。服务器执行两次调用、对错误对象执行调用，或者比代理预期晚五分钟执行，会发生什么？这些问题能区分普通读取和需要仔细审查的操作。

我在审查候选操作时会使用四种影响类别：

- 观察：获取范围明确的信息，不产生服务器端变更。
- 可逆变更：创建、更新或删除某项内容，并且有记录清楚且实际可行的撤销路径。
- 对外承诺：发送消息、启动付费任务、发布材料或改变面向客户的状态。
- 不可逆或广泛变更：永久删除记录、轮换访问权限、修改权限，或影响大量对象。

这些类别不是权限模型，但能迫使描述更加诚实。`create invoice` 即使只有两个字段，也属于对外承诺。`restart environment` 如果一个环境包含许多服务，也可能属于广泛变更。

不要从方法名推断安全性。HTTP 在协议层面将 `GET` 定义为安全方法，意思是客户端不应通过它请求状态变更。这是一种约定，并不能证明某个服务器确实遵守它。我遇到过一些诊断端点，反复调用时会刷新缓存、触发报告生成，并消耗稀缺容量。要测试实际行为，不要相信动词带来的暗示。

还要把操作的影响与结果的敏感性分开。获取访问令牌可能是只读的，但把令牌返回给代理会破坏控制调用的意义。获取私密客户记录也可能需要审批，即使 API 从未改动任何字节。

一张好的操作卡片会用直白的语言记录这两个维度：

```text
Action: promote_preview_build
Effect: Changes one named preview build into the staging release channel.
Scope: One project and one build ID.
Result: Release ID, resulting channel, and server timestamp.
Human consent: Required for every call.
Retry: Never retry automatically unless the server accepts the same idempotency token.
```

这张卡片经常能在代理写下一行代码前暴露 API 语义中的缺口。如果没人能说清楚重试是否安全，操作就还没有准备好。

## 输入需要代理无法绕过的边界

代理操作需要的输入契约通常比端点实际接受的内容更小。OpenAPI 模式定义了类型和结构，但代理还需要其他限制，防止它通过富有创意的参数扩大任务范围。

以创建部署的操作为例。原始 API 可能为内部客户端提供许多选项：环境、构件引用、区域、副本数量、环境变量、功能开关、标签以及自由格式的配置对象。把每个字段都交给代理，会把一个简单请求变成未经审查的管理入口。

创建与任务相符的操作输入。如果任务是「把通过测试的构建部署到预览环境」，代理可能只需要 `project_id`、`build_id` 和简短的 `reason`。执行器可以选择允许的环境，并拒绝超出操作范围的内容。

下面的请求形状让边界变得具体：

```json
{
  "project_id": "proj_4821",
  "build_id": "build_9017",
  "reason": "Preview requested after integration tests passed"
}
```

不要因为底层端点支持，就顺手加入 `target_url`、任意 `headers`、原始请求体或通用的 `options` 对象。每个逃生口都会把这个精心命名的操作重新变成通用客户端。

利用 OpenAPI 中已经能表达限制的字段。当对象只应接受列出的字段时，设置 `additionalProperties: false`。只有在允许值确实很少时，才使用 `enum`。如果标识符有固定格式，就设置长度和模式限制。当执行器无法安全推断某个字段时，将其标记为必填。

例如，下面的片段会拒绝未经审查的配置字段，并在模式中显示预期范围：

```yaml
DeployPreviewRequest:
  type: object
  additionalProperties: false
  required:
    - project_id
    - build_id
    - reason
  properties:
    project_id:
      type: string
      pattern: '^proj_[A-Za-z0-9]+$'
    build_id:
      type: string
      pattern: '^build_[A-Za-z0-9]+$'
    reason:
      type: string
      minLength: 8
      maxLength: 240
```

`additionalProperties: false` 可以防止一种常见失败：代理从另一个 API 示例中得知可以发送 `environment_variables`，于是把密钥或不安全的覆盖设置放进去，而服务器又默默接受。拒绝该字段会给代理一个有用的错误，而不是一次意外部署。

模式不能替代对象级授权。有效的 `project_id` 仍然可能指向任务范围之外的项目。执行器必须检查请求对象是否属于允许的账户、工作区、代码库或环境。把这项检查放在代理执行器附近，不能依赖代理的解释。

自由文本需要特别处理。理由字段可以帮助审查者，但绝不能变成执行器的指令通道。把它保存为审计注释，不要将其解析为命令、资源选择器或权限例外。

## 预期结果必须支持下一步决策

代理需要的是可以推理的结果，而不是把原始 HTTP 响应全部倾倒进上下文。返回每个请求头、调试字段和嵌套对象会增加混乱，还可能泄露代理完成任务并不需要的数据。

在选择响应代码之前，先用业务语言定义成功。对于部署操作，有用的结果应标明部署、当前状态以及服务器之后报告进度的位置。对于记录更新，应标明记录并确认哪些字段发生了变化。对于删除，应确认目标以及是否仍然可以恢复。

异步操作的简洁结果可以这样写：

```json
{
  "status": "accepted",
  "deployment_id": "dep_2388",
  "project_id": "proj_4821",
  "build_id": "build_9017",
  "target": "preview",
  "operation_status": "queued"
}
```

这个响应表达得很准确：服务器接受了任务，但部署尚未完成。代理收到它后不应报告「已部署」，而应使用单独的只读状态操作，或告诉用户操作正在排队。

许多 OpenAPI 文档正是在这里误导代理。`202 Accepted` 有明确含义：服务器接受了处理请求，但处理可能尚未开始，也可能尚未完成。把 `202` 当作与已完成的 `200` 相同的成功状态，会让日志和用户消息出现错误结论。

将传输层结果与操作结果分开。HTTP `200` 可能包裹着这样的领域失败：`{"state":"rejected","reason":"build is not eligible"}`。反过来，`409 Conflict` 可能告诉代理目标状态已经存在。操作包装器应把这些情况转换成少数明确状态，例如 `completed`、`pending`、`already_in_desired_state`、`rejected` 和 `unknown`。

不要假装所有 API 都能提供统一结果。有些 API 只返回不透明的任务 ID，这没有问题，只要你提供能解析它的状态操作。真正的错误是隐藏这个缺口。要明确说明第一次调用确认了什么，以及没有确认什么。

错误返回代理前，应先过滤其中的细节。服务器错误可能包含内部 URL、授权请求头、堆栈跟踪或其他用户的数据。代理需要的是可以据此行动的原因，例如「构建 ID 不属于项目 ID」，以及供人调查的安全关联 ID。它不需要上游异常页面。

## 在产生承诺的地方进行审批

当调用可能产生重要承诺时请求审批，并让审批界面说明对象和影响。一次性为未来一大组模糊权限请求审批，会让人习惯点击自己无法判断的警告。

会话审批和单次调用审批解决的是不同问题。会话审批表示：「我知道这是哪个代理进程，并允许它在运行期间使用这组操作。」单次调用审批表示：「我现在批准这一个具有实际影响的请求。」不要用一种替代另一种。

能够查看构建状态的代理可以运行一小时而不打扰任何人。要提升构建的代理，则应在请求同意的那一刻展示项目、构建 ID、发布渠道和理由。审查者可以据此判断。「允许部署工具」几乎没有可判断的信息。

不要把审批提示当作输入验证的替代品。如果一个操作允许代理指定任意目标或任意权限范围，审查者就必须在压力下解读一份庞大且不断变化的载荷。先限制输入，再让审批确认范围明确的操作。

审批频率取决于影响。对于发布内容、改变访问权限、发起外部付款或触及广泛生产范围的操作，应逐次调用审批。只读调用或范围狭窄的可逆变更可以使用会话同意，但前提是审查者能看到进程身份和操作目录。

Sallyport 将这一区分落实为：新发现的代理进程需要会话授权，特定凭据的每次使用还可以要求单独审批。它的保险库门禁在锁定时会拒绝所有操作，因此审批不会把已锁定的密钥存储变成意外例外。

不要让人审批软件本可以阻止的失败。如果构建不符合发布条件，执行器应在发起审批前拒绝它。提示应该用于合法选择，而不是让疲惫的审查者帮忙发现格式错误的状态。

## 超时会产生未知状态，而不是重试指令

变更请求发出后网络超时，是最能暴露代理操作设计粗糙程度的失败路径。代理已经发送请求，却丢失了响应。服务器可能什么也没做，可能已经完成变更，也可能仍在处理。代理不能通过假定自己想要的答案来得知真相。

来看一个常见失败。代理调用 `POST /v1/invoices`，传入客户、金额和请求超时时间。服务器提交发票后、响应返回前连接断开。代理看到超时，于是用同样的数据重试，服务器创建第二张发票。审计日志可以说代理遵循了重试策略，这在技术上没错，但在实际运营中毫无用处。

幂等令牌只有在服务器真正实现它时才有用。客户端为每个预期操作生成一个令牌，在初始请求中发送，并在重试时发送完全相同的令牌。服务器必须将令牌绑定到原始请求，并返回原始结果或兼容的冲突结果，而不是重复执行影响。

```text
Idempotency-Key: act_01HZX7FQ2Z9K8M6R4T3V1W0Y
```

操作包装器必须让代理无法自行编造这个令牌。在执行时生成令牌，将其与操作尝试一起持久化，并且只在这次尝试中重复使用。代理提供的令牌可能发生冲突、被用于无关请求，或成为另一个提示注入入口。

如果 API 没有记录清楚的幂等语义，就不要在变更操作超时后自动重试。返回带有操作标识符的 `unknown`，并提供只读查询操作来检查服务器状态。如果连查询都没有，人必须调查后才能重复请求。这个答案让人不方便，是因为事情本来就不方便。假装确定并不会改善情况。

OpenAPI 可以记录名为 `Idempotency-Key` 的请求头参数，但文档本身不能保证服务器行为。应有意测试它：用同一个令牌和载荷发送两次，再用同一个令牌发送不同载荷。服务器应让前两次请求收敛到同一结果，并拒绝或明确处理变化后的请求。如果它默默执行了两次变更，这个请求头只是装饰。

其他失败也需要各自的规则。将 `401` 和 `403` 视为停止条件，不要借机寻找另一份凭据。只有当 API 提供重试延迟，或操作有明确的退避策略时，才把 `429` 视为等待条件。只有当验证错误指出了允许的修正方式时，代理才能将其当作反馈使用。

## 身份验证不会赋予代理判断力

OpenAPI 的 `security` 声明描述客户端如何向 API 证明身份。它不会说明代理是否应该调用操作，也不会说明它能否用某份凭据访问某个对象，更不会说明某种影响是否需要人审查。

Specification 中的 Security Requirement Object 会把操作与命名的安全方案关联起来。bearer 方案可能告诉客户端发送授权请求头，基本身份验证可能告诉客户端构造凭据请求头。这些都是传输层身份验证，不要从中推导出更多含义。

将下面四个问题分开：

- 谁或什么在调用这个操作？
- 执行器向上游 API 使用哪份凭据？
- 这份凭据允许访问哪些对象并产生哪些影响？
- 哪些操作尝试需要人批准？

团队一旦把这些问题混在一起，通常就会把令牌交给代理，然后称之为授权。令牌随后可能出现在工具输出、Shell 历史、调试日志、提示词或配置文件中。撤销它就会变成一项清理工程，而不是一次简单操作。

更安全的方式是把凭据留在执行器中。代理只提供范围明确的操作输入。执行器选择符合条件的凭据，将其注入 HTTP 请求，评估响应，并返回过滤后的结果。代理不需要获得 API 密钥明文就能请求操作。

SSH 也应遵循同样的规则。代理可能需要请求在指定主机上执行一条命令，但把通用命令和广泛信任的私钥交给代理，权限范围远超大多数任务所需。应根据操作目的限制主机身份、账户、命令形式和输出处理。

Sallyport 使用这种执行模式处理 HTTP 调用和 SSH 命令：凭据留在加密保险库中，代理收到的是操作结果而不是秘密。只有在继续开放范围狭窄的操作，并选择与其影响相匹配的审批方式时，这种设计才真正有帮助。

## 操作描述必须说明模式无法表达的内容

OpenAPI 描述很重要，因为代理会把它们当作指令。但描述应该解释边界，而不是偷偷塞入另一份相互矛盾的 API 契约。可执行的限制放在模式和执行器中，描述则用于说明意图、影响以及类型系统无法表达的条件。

根据用户预期的结果命名操作。`getBuildStatus` 比 `getBuildById` 表达得更多，`createPreviewDeployment` 比 `postDeployment` 更清楚。名称不能过度承诺。如果服务器只是把任务排队，就不要把操作叫作 `deployBuild`，除非结果能够区分「已接受」和「已完成」。

描述中应写出代理原本可能猜测的细节：

```yaml
operationId: createPreviewDeployment
summary: Queue one tested build for the preview environment
requestBody:
  required: true
  content:
    application/json:
      schema:
        $ref: '#/components/schemas/DeployPreviewRequest'
responses:
  '202':
    description: Request accepted. Deployment work may still be pending.
  '409':
    description: The build already has a preview deployment or cannot enter preview.
```

对于影响较大的操作，仅有摘要并不够。应在 OpenAPI 文档旁边的操作元数据中记录目标、影响类别、审批要求、重试规则和结果状态。可以使用 `x-` 扩展，前提是工具链明确由你管理，但要清楚标注这是私有约定。标准 OpenAPI 解析器会忽略未知扩展，因此执行器必须真正执行这些规则，而不能只是把它们显示出来。

不要依赖「请谨慎使用」这样的描述。谨慎是一种人的感受，不是可执行规则。应替换成具体限制：只能操作一个项目，只能使用预览环境，不允许任意环境变量，每次调用都要审批，未知结果后不得自动重试。

描述还应告诉代理什么时候必须拒绝操作。发布操作可以要求测试运行已经完成。数据导出可以要求客户提供案件编号。删除操作可以要求先查询并确认目标是草稿。这些前置条件可以减少无意义的提示，也让审计记录更容易解释。

## 用一个粗心但有能力的操作者测试操作

只测试成功路径，只能证明 API 在所有假设都成立时可以工作。测试操作时，应假设操作者能力很强，但上下文不完整，手里的标识符已经过期，而且在出错后容易重复尝试。这与自主代理的失败方式已经足够接近，测试才有价值。

用可丢弃的对象和权限符合预期执行器的账户，建立一个小型测试环境。然后运行下面这些挑战边界的案例：

- 发送未知输入字段，确认执行器会拒绝。
- 请求允许的项目或工作区之外的对象。
- 拒绝审批，确认不会发出上游请求。
- 在服务器收到变更请求后强制超时。
- 返回包含敏感调试材料的响应，确认过滤器会将其移除。

不要只检查最终的 API 状态。还要审查人看到的提示、执行器发出的准确请求、代理收到的结果以及审计记录。请求成功仍可能违反操作契约，例如提示隐藏了目标，结果过早声称完成，或者日志无法区分被拒绝的请求和上游拒绝。

对于每次调用都需要审批的操作，要测试执行顺序。执行器应先验证静态限制，并解析足够安全的上下文，以便在请求同意前展示有意义的请求。不能先发送请求再询问审批，也应避免进行一长串隐藏的读取调用，暴露最终操作并不需要的数据。

有意测试已撤销和已过期的凭据。执行器应安全失败，返回安全的解释，并避免使用同一份不可用凭据反复调用。针对被拒绝凭据的重试循环会填满日志、触发速率限制，并让简单的访问问题更难诊断。

最后，测试取消操作。如果用户在上游任务运行时停止代理，记录应说明请求从未发出、已经到达服务器，还是进入了未知状态。取消本地代理进程不一定会取消远程影响。

## 少发布一些操作，让每个操作都经得起解释

小型操作目录胜过通用 API 客户端，因为每个操作都可以拥有经过推敲的契约。增加操作很容易，真正困难的是持续维护真实的结果语义、对象边界、审批提示和失败行为。

先从一个获取范围明确的状态记录的操作开始。用名称说明对象，将标识符限制在预期范围内，只返回代理需要的字段。然后增加一个可逆操作，并在实现它之前强迫自己写清楚重试和审批规则。

不要因为 OpenAPI 生成器能在一个下午暴露某个操作，就把它升级为代理操作。只有在能够解释超时后会发生什么、人批准的是什么、代理能看到什么，以及之后如何证明具体发生了哪次请求时，才应该把它加入目录。如果其中任何答案依赖于「代理大概会做出合理选择」，就不要把这个端点放进目录。
