# 无秘密代理工具：经得起考验的操作契约

代理应该请求执行操作，而不是拿到冒充某个人或服务账户所需的手段。道理听起来很明显，但检查一个典型的代理工具就会发现问题：`http_request` 函数接受 URL、请求头、方法和请求体，而代理从环境变量中获得 bearer 令牌。工具调用看起来很整洁，权限却散落在提示词文本、进程内存、日志、shell 历史记录，以及代理接下来启动的任何子进程中。

无秘密设计会在意图与执行之间建立一道明确边界。代理说：“为这个环境中的这个服务创建一次部署。”拥有凭据的执行层决定是否允许该操作，选择正确的身份，发起经过身份验证的调用，并返回结果。这样一来，操作就更容易审计、审批和撤销，也会迫使你设计出真正配得上自主执行的接口。

## 契约必须描述意图，而不是传输方式

操作契约应给出用户能够理解的操作名称，并将输入限制为该操作真正需要的事实。传输细节应留在边界之后。这个区别很容易被忽略，因为 HTTP 会把每个操作都表现成方法、URL、请求头和 JSON 请求体。

下面比较两个用于创建变更请求的工具接口。第一个很常见，但不适合自主进程：

```json
{
  "name": "http_request",
  "input": {
    "method": "POST",
    "url": "https://code.example/api/projects/alpha/changes",
    "headers": {
      "Authorization": "Bearer ${TOKEN}",
      "Content-Type": "application/json"
    },
    "body": {
      "title": "Fix timeout",
      "branch": "agent/fix-timeout"
    }
  }
}
```

这个接口让代理可以控制目标地址、身份验证方式和请求结构。即使移除了明文令牌，只要代理还能选择请求头别名、凭据标识符、代理 URL，或启动一个会从别处读取令牌的 shell 命令，问题就没有解决。你只是移动了秘密，并没有减少权限。

面向契约的接口更接近下面这样：

```json
{
  "name": "create_change_request",
  "input": {
    "project": "alpha",
    "source_branch": "agent/fix-timeout",
    "title": "Fix timeout in retry path",
    "description": "Adds a bounded retry and a regression test."
  }
}
```

执行层会将 `project` 映射到已知端点和获批账户，并自行添加身份验证请求头。它可以拒绝分支名称，验证目标仓库，请求审批，或者返回远程服务的错误。代理没有任何参数可以表达“使用权限最大的凭据”。

人们经常混淆一个重要区别：**无秘密不等于隐藏令牌**。隐藏令牌试图控制代理在获得权限之后能看到什么。操作契约则从一开始就不让代理持有这项权限。如果模型提示词泄露、工具记录被复制，或子进程读取了环境变量，第一种设计已经丢失凭据。第二种设计可能暴露操作数据，这些数据需要自己的控制措施，但不会交出签名材料。

契约也不应假装每个端点都值得创建一个专用工具。人在一句话中能够表达预期结果时，自定义操作才有意义。“重启这个预发布环境中的工作负载”是一个结果。“向任意 URL 发送 PATCH 请求”是一个传输原语。如果维护任务确实需要这个原语，应将它交给一个独立且严格受限的集成，而不是通用编程代理。

## 凭据所有者必须执行请求

如果代理仍在挂载秘密的进程中执行最终网络调用，那么契约并不能保护任何东西。存储凭据的组件必须自己发起 HTTP 请求或 SSH 连接。

这意味着执行边界要承担五项工作：

- 将操作名称解析为固定目标和固定的协议行为。
- 从少量获批身份中选择一个存储的身份。
- 仅在出站请求或 SSH 身份验证交换中注入凭据。
- 记录请求、决定和结果，但不把秘密材料写入记录。
- 返回适合该操作的响应，而不是内部状态的完整转储。

模型进程不应接收令牌或私钥，包括临时令牌和临时私钥。避免使用 `TOKEN=$(vault read ...)` 这样的 shell 约定，避免在工作目录中放置凭据文件，避免在生成的 curl 命令中写入 `Authorization` 值，也不要让代理运行的 shell 共享 SSH agent。这些做法看起来方便，因为它们保留了现有脚本，但都会让代理进程成为凭据持有者。

IETF 的 OAuth 2.0 Security Best Current Practice 在另一个场景中表达了同一个实际要点：必须保护 bearer 令牌在存储和传输中的安全，因为任何持有令牌的人都可以使用它。告诉模型不要打印令牌，并不能让 bearer 令牌变得安全。持有本身就是授权检查。对于代理工具，更好的设计是避免让进程持有令牌。

对于 SSH，边界需要拥有的不只是私钥。即使密钥从未离开辅助工具，原始的 `ssh host command` 接口仍会给代理很大的操作范围。辅助工具应选择存储的主机定义和身份，然后强制使用适合该主机的命令形式。部署主机可以允许带服务名称的 `status`、`restart-service` 和 `tail-release-log`，但不应因为有人想走捷径，就悄悄接受 `bash -c`。

不要把这和中间人代理混为一谈。代理会转发任意客户端流量，通常也会在传输途中看到凭据。拥有凭据的操作层接收具名操作请求，构造出站调用，并将凭据保留在自己的保险库中。这个区别决定了代理能否把一个获批操作变成另一个操作。

## 参数设计决定会泄露多少权限

契约中的每个字段都会创造一个自由度。好的字段用于标识工作对象，或提供该操作真正需要的内容。坏的字段会改变权限流向、使用的身份，或要执行的底层操作。

对每个拟加入的输入都做一次检查：如果代理改变这个值，它能否将特权请求重定向到另一个系统，扩大受影响资源的范围，或改变身份验证方式？如果可以，就移除该字段，将它改成由执行器映射的枚举，或把操作拆成多个独立契约。

部署接口可以说明这一点：

```json
{
  "name": "deploy_release",
  "input_schema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["service", "environment", "version", "reason"],
    "properties": {
      "service": {"type": "string", "enum": ["api", "worker"]},
      "environment": {"type": "string", "enum": ["test", "production"]},
      "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"},
      "reason": {"type": "string", "maxLength": 500}
    }
  }
}
```

这个模式会阻止 `url`、`headers`、`credential_name` 或 `command` 等意外字段。执行器可以将 `service` 和 `environment` 映射到已知部署目标。`additionalProperties: false` 的重要性超出很多人的想象。如果没有它，宽松的验证器可能会保留无法识别的字段，之后有人为了“灵活性”把该字段接入 HTTP 客户端。一个看似无害的扩展点，就这样变成了凭据逃逸通道。

枚举并不总是答案。仓库名称、分支、工单编号或文件路径可能确实需要变化。应根据各自的领域验证这些值，并在解析之后再执行边界检查。例如，先通过本地允许列表解析仓库标识符，再使用映射后的远程位置。不要接受仓库 URL，然后试图判断它看起来是否友好。

自由文本需要单独判断。代理可能需要撰写问题描述、拉取请求摘要或支持回复。这些文本属于内容，而不是权限，但仍可能通过提及、标记、模板，或下游服务执行的嵌入式命令造成危害。限制文本长度，明确其渲染行为，不要将它插入 shell 命令。操作必须执行命令时，应直接构造参数数组，让不受信任的文本只作为数据参数存在，绝不能放进命令字符串。

## 通用请求工具会制造隐藏的策略引擎

通用 HTTP 工具很受欢迎，因为团队可以在一个下午内把代理接入任何服务。但对大多数特权代理工作来说，它并不合适，因为每条提示词、每个工具描述和每个代码分支都会变成非正式的授权策略。

团队通常会从下面这样的包装器开始：

```text
request(method, url, headers, body)
```

然后不断添加防护措施：阻止几个域名，移除 `Authorization`，只允许某些方法，解析 URL 前缀，拒绝 `localhost`，对高风险调用显示审批对话框。几个月后，有人需要一个带自定义请求头的新端点，于是加上例外。包装器逐渐拥有了一套没有测试、也没有明确负责人的策略语言。

问题并不是通用工具永远有害。它适合人工操作的调试控制台，因为操作员本来就拥有权限，可以检查每个字节。它也适合由受控代码调用、并拥有狭窄网络身份的集成服务。自主代理不同，它可以发起大量调用，发现意外路径，并根据不受信任的文本采取行动。因此它需要更少的自由度。

围绕稳定的工作单元编写具名操作。对于源代码管理服务，优先使用 `read_merge_request`、`comment_on_merge_request` 和 `create_branch`，而不是通用 REST 客户端。对于运维，优先使用 `get_service_status`、`fetch_release_logs` 和 `request_deployment`。契约可能会更多，但每个契约都有负责人、测试集、清晰的审批标签，以及可以审查的影响范围。

也不要在具名操作内部隐藏通用请求。名为 `update_ticket`、却接受任意 `path`、`method` 和 `body` 的工具，只是换了标签。契约必须绑定这些细节。如果下游 API 需要，可以公开受控的补丁对象，但端点、HTTP 方法、内容类型和账户都应由执行器决定。

Model Context Protocol 规范通过允许服务器向客户端发布工具名称、描述和 JSON 输入模式，帮助实现工具发现。这个模式很有用，但无法让过于宽泛的操作变得安全。JSON Schema 可以告诉你 URL 是字符串，却无法告诉你它是否是生产凭据唯一允许访问的计费端点。授权仍然是执行层的职责。

## 审批应当使用人能够判断的操作名称

当人看到的是一个易于理解的请求，并且可以迅速拒绝时，人工审批才有效。如果审批提示要求人在代理已经做出重要选择之后，去批准一组不透明的传输细节，审批就会失效。

比较下面两张审批卡片：

```text
Allow POST https://api.example/v1/resources/882?
Headers: Authorization, X-Region, X-Client
```

```text
Deploy version 2.14.3 of api to production
Reason: Fixes failed payment retries
Requested by: signed agent process build-worker
```

第二张卡片让操作员能够判断意图，也让审计记录拥有一句有用的话。第一张卡片要求操作员从 URL 和请求头列表中重新推断含义，很容易造成审批疲劳。普通代理运行一次就可能生成多张审批卡片，而人们尤其容易在看不懂内容时直接点击通过。

应在决定会改变权限的地方进行审批。执行层可以在一个会话中为新的代理进程授权一次，然后针对选定的敏感凭据或破坏性操作要求重新决定。这样既能让日常工作保持顺畅，又不会把所有凭据视为同等重要。只读项目令牌和生产部署身份不应仅仅因为都通过 HTTP 请求头传递，就共享同一审批规则。

审批文字必须说明是谁请求了操作。进程身份很有用，因为终端代理、后台辅助进程和未知可执行文件不应获得相同程度的信任。在 macOS 上，代码签名信息可以为审批者提供具体的来源信号。它不能证明每条提示词指令都安全，但能回答第一个问题：哪个进程正在请求使用这个账户执行操作？

绝不要把审批当成唯一控制措施。人可能误读提示，在时间压力下批准，或让会话一直处于开放状态。契约仍需限制输入，并固定凭据路径。反过来，如果清晰的操作契约加上一项审批选择已经足够，就不要再添加一套策略语言。比较任意字段、时间窗口、正则表达式和用户声明的规则，很快就会变成另一套没人能在事故期间有把握审查的程序。

## 一次失败的部署揭示了宽松契约的断裂点

一种常见的失败始于这样的代理：它可以通过 shell 工具部署到测试环境。团队将云令牌存入代理环境，因为部署 CLI 需要它。工具模式接受 `environment` 和 `extra_args`，而当时只有测试环境，所以看起来没什么问题。

一张工单要求代理“在测试环境验证紧急修复并分享结果”。代理运行了预期命令，随后在仓库中看到一条过时的部署消息，于是尝试使用从旧脚本复制来的额外参数。这个参数可能选择生产环境，改变目标账户，或注入 shell 扩展。由于维护独立凭据似乎太麻烦，令牌拥有生产权限。到了这一步，提示词措辞已经救不了你。进程持有宽泛权限，接口又允许它选择目标。

契约边界会改变整个过程：

1. 代理使用枚举的服务、环境、版本和原因调用 `deploy_release`。
2. 执行器将环境解析为固定目标，并选择分配给该目标的身份。
3. 如果该身份需要审批，执行器会请求决定，然后将结果记录在发起请求的代理运行记录下。
4. 执行器返回部署标识符和状态，或返回结构化拒绝信息，说明代理为什么无法继续。

代理无法添加 `--account`，无法设置云端点，也无法读取令牌。错误地填写 `production` 仍然可能发生，因为人和模型都可能请求错误的操作。但现在审批文字会用清晰语言写出 production，所选身份可以只拥有生产部署所需的权限，操作记录也会将决定与进程和请求关联起来。

这个区别在诊断期间非常重要。在宽松设计中，调查人员往往只能找到一些碎片：shell 记录、云审计事件、CI 日志，以及可能需要立即轮换的令牌值。在契约设计中，他们可以检查请求的操作、解析后的目标、身份标签、审批结果、响应状态，以及发起操作的会话。审计轨迹无法抹去错误，但能缩短猜测实际执行路径所需的时间。

## 错误响应应指导恢复，同时不暴露内部细节

安全的操作层应返回代理可以采取行动的错误，但不应返回秘密、请求签名数据或内部保险库结构。过于模糊的失败会促使代理不断重试和寻找变通办法。过于详细的失败则会让错误日志变成信息通道。

使用稳定的错误代码和小型公开结构：

```json
{
  "ok": false,
  "error": {
    "code": "APPROVAL_REQUIRED",
    "message": "Deployment to production needs user approval.",
    "retryable": true,
    "request_id": "act_01H..."
  }
}
```

锁定的保险库应报告 `VAULT_LOCKED`，用户拒绝决定应报告 `APPROVAL_DENIED`，契约违规应报告 `INVALID_ARGUMENT`，下游 429 响应可以报告 `REMOTE_RATE_LIMITED`。代理可以报告当前状态，等待条件改变后重试，或采取非破坏性替代方案。它不应收到包含原始授权请求头、令牌主体转储、私有主机配置或完整签名请求的错误。

应区分授权失败和远程失败。“权限被拒绝”可能意味着本地执行器拒绝了操作，也可能意味着选定的远程账户没有权限，或下游服务拒绝了格式错误的身份验证。这些情况需要不同的修复方式。公开消息可以保持简洁，而执行器的受保护审计记录则可以保存精确原因代码和远程状态。

重试需要符合契约语义。读取操作通常可以安全重试，但创建工单、发送消息或启动部署可能不行。在远程 API 支持时加入幂等标识符，由执行器生成，或由调用方提供受限的请求标识符。发送请求前先记录关联关系，然后在重试时复用它。不要让代理每次看到超时都生成新的标识符，否则它可能在试图帮忙时创建重复工作。

对于不支持远程幂等性的操作，可以使用准备和确认安排。准备操作返回一个短期有效的计划，说明目标和差异。确认操作引用该计划，并要求当前审批。虽然这会增加一次往返，但比在网络失败含义不明后重复付款、删除或生产变更便宜得多。

## 审计记录需要两个视图和一个事实来源

有用的审计系统要回答两个不同问题：这次代理运行尝试执行了什么，以及每次特权调用做了什么？如果把两者合并成一条没有区分的事件流，人们要么难以还原会话，要么难以找到某一项请求。

为每次运行保留会话日志。它应显示进程身份、开始和结束时间、授权决定、撤销状态，以及该运行期间请求的操作。为调用保留活动日志。它应显示操作名称、规范化参数、不包含秘密的凭据标签、审批结果、时间信息、目标类别和结果。

两个视图都应来自同一份只追加记录。否则，当某个写入器崩溃，或不同组件以不同方式过滤事件时，会话界面和调用日志可能不一致。只能写入而不能读取的加密日志还有一个实际优势：追加事件的组件无需解密过去的记录，就能写入新记录。

防篡改证据需要离线检查。哈希链可以让验证者在拥有日志序列时，检测记录删除、替换或重新排序。检查应针对密文运行，这样审计员无需获得保险库密钥也能验证连续性。但这不能证明遭到入侵的机器从未漏记事件，只能证明保留下来的链在事后没有被悄悄编辑。两者是不同的结论。

命令行验证器应让失败位置清晰可见。输出可以简单到这样：

```text
$ sp audit verify audit.log
verified: 184 records
first sequence: 9012
last sequence: 9195
chain: valid
```

如果第 9137 条记录被修改，命令应指出第一个断裂的序号，并以非零状态退出。不要只报告“验证失败”。事故响应人员需要知道从哪里开始，证据不再可信。

Sallyport 使用这种分视图方式记录代理会话和单独的活动，并从同一份加密、带哈希链的审计日志中生成两个视图。`sp audit verify` 无需保险库密钥即可检查该日志。对于本地代理网关来说，这是正确的形态，因为撤销正在运行的会话和调查单次调用是两项不同工作。

## 契约需要尝试逃逸的测试

成功路径测试只能证明操作能够运行。安全测试则要证明，声明的输入是调用方拥有的全部控制手段。在添加便利参数之前编写这些测试，因为权限往往就是通过便利参数重新渗入的。

对每个操作，至少测试以下情况：

- 拒绝未预期字段，包括 `headers`、`url`、`command` 和凭据引用。
- 拒绝解析后超出操作允许资源集合的值。
- 确认只有在执行器构造目标之后，出站 HTTP 请求才会获得凭据。
- 确认审计条目不包含令牌值、私钥材料和签名授权字段。
- 确认被拒绝或已撤销的会话无法复用之前的审批。

在测试中使用虚假的出站服务器，并检查它实际收到的请求。测试应断言执行器构造出的真实 URL、方法和请求头，以及调用方无法控制的身份验证是否缺失。只模拟执行器内部客户端，会错过最重要的问题：如果代理提供恶意参数，哪些内容会离开这台机器？

还要测试模型最终可能生成的棘手输入：带有 scheme 前缀的仓库标识符，含 shell 标点的分支名称，环境标签中的 Unicode 视觉相似字符，重复的 JSON 字段，超长描述，以及远程服务已接受操作后发生的超时。契约验证应默认拒绝。如果执行器无法有把握地解析请求目标，就应拒绝调用并返回有用错误。

像审查代码一样审查拥有权限的契约。问问自己，新字段是否让调用方获得了通往另一个主机、更宽泛账户、不同命令或不同对象类型的路径。如果是，就应在操作名称和审批行为中明确表达这项权限。带有自由格式绝对路径的 `delete_file` 操作，比在已知项目内解析资产标识符的 `remove_preview_asset` 更难理解和控制。

通常最值得首先修复的契约，是接受任意 URL 或 shell 字符串的契约。将它替换成能够覆盖实际工作需求的最小具名操作。接口会变得不那么聪明，代理也会以你以后能够解释的方式变得不那么强大。这就是进步。
