# 帮助代理恢复，同时不暴露机密的工具错误

代理工具错误需要提供足够的结构，让代理能够恢复，但不能把每一次失败的请求都变成凭据导出。这个边界说起来很容易，实际却经常被突破：包装器捕获异常，直接返回库的错误消息，不知不觉就把 URL、Authorization 请求头、证书主题、SSH 目标、或令牌片段交给了模型。

我见过问题从一个出于好意的调试字段开始。代理不断收到 401 响应，于是有人加入了 `request_headers`。代理把错误复制到工作笔记中。这些笔记后来被粘贴进拉取请求，发送到支持系统，或保留在评估跟踪记录里。原始故障已经过去，机密却没有。

正确的设计应向代理说明发生了什么、接下来可以安全地做什么，以及供人工操作员使用的关联句柄。原始证据应留在操作边界之后。这不是在有用的错误和安全的错误之间二选一，而是接口设计问题。

## 错误响应也是安全边界的一部分

代理会把工具输出当作工作记忆。它可能向用户引用这些输出，把它们放进文件，发送给另一个工具，或用它们决定是否重试。因此，操作网关返回的任何字段都必须被视为向不可信调用方披露的信息，即使调用方是开发者在自己机器上启动的代理。

一次工具调用有两个受众。代理需要操作事实：操作是否执行、能否重试、是否需要同意，以及应修改哪一项输入。操作员需要取证事实：选择了哪项凭据、调用了哪条确切路径、哪个 DNS 解析器失败，以及远程对端返回了什么。不要为了满足第二个受众，把证据倾倒给第一个受众。

当代理可以反复调用工具时，这个区别尤其重要。人类看到一次详细错误，可能会注意到其中的 bearer 令牌。代理却可能在数十轮对话中保留它，之后把它放进生成的代码或测试夹具中。即使模型没有恶意，也可能发生这种事。

围绕一条明确规则设计接口：如果某个字段被复制到公开问题中仍然安全，调用方才可以收到它。不符合这项测试的字段，应存入受保护的诊断记录，或直接省略。

## 给代理决策信息，而不是异常字符串

有用的响应应告诉代理面对的是哪一类决策。原始异常既不可靠，也不安全。它们的措辞会随着操作系统版本和客户端库版本变化，而且经常把技术原因和必须保密的材料混在一起。

使用一组小而有文档说明的代码词汇。每个代码都应对应一种明确的调用方行为。不要把代码设计得过于细碎，否则最终会把每一种可能的上游失败重新复制一遍。

一种实用的响应结构如下：

```json
{
  "ok": false,
  "code": "AUTH_FAILED",
  "message": "The remote service rejected the stored credential.",
  "action": "stop_and_report",
  "retryable": false,
  "request_id": "act_7f3c2a91",
  "http_status": 401
}
```

代理可以停止操作，告诉用户身份验证需要处理，并附上请求 ID。它不需要 bearer 令牌、授权方案、账户邮箱或复制的响应正文，也能做出这个决定。

单独使用模糊的布尔值时，`action` 更有用。布尔值只能说明等待一段时间是否可能解决问题，操作字段则直接告诉代理应该做什么。允许的值应保持精简：

- `retry_after_delay`，用于暂时性且可安全重复的操作。
- `repair_input`，用于代理无需新的权限即可修正的请求。
- `request_approval`，用于必须由人批准的操作。
- `stop_and_report`，用于需要操作员处理的失败。
- `inspect_outcome`，用于写操作可能已经到达远程服务的情况。

最后一种情况需要特别处理。发送写请求后发生的超时，与发送前发生的超时并不相同。如果把两者都压平为 `NETWORK_ERROR`，代理就可能重试一个已经完成的操作。重复工单、部署、记录和破坏性命令都可能因此产生。

只有在有意义且安全时才暴露 `http_status`。它通常是 HTTP 调用的有用上下文，但不要假装它能给出完整答案。403 可能表示上游授权失败、资源级限制，或网关决定。工具代码必须明确告诉代理应采取什么行为。

## 让稳定的代码分类足够小，便于测试

代码分类应描述责任归属和恢复方式，而不是网络栈的每一层。如果第一周过后列表就有五十个代码，你可能只是在用另一个名字导出实现细节。

先从能让调用方采取不同操作的类别开始：

| Code | Meaning | Agent behavior |
| --- | --- | --- |
| `INVALID_INPUT` | 工具在外部操作前拒绝了提供的字段。 | 修正输入。 |
| `VAULT_LOCKED` | 网关无法使用任何已存储的机密。 | 请求人员解锁。 |
| `USER_DENIED` | 人员拒绝了这次操作。 | 停止，不要改写后再次提交。 |
| `AUTH_FAILED` | 远程服务拒绝了选中的凭据。 | 停止并报告。 |
| `REMOTE_FORBIDDEN` | 请求已通过身份验证，但没有远程权限。 | 停止并报告。 |
| `RATE_LIMITED` | 远程服务要求调用方降低速度。 | 存在安全延迟时等待。 |
| `TEMPORARY_FAILURE` | 可重复的请求暂时失败。 | 在有限预算内重试。 |
| `OUTCOME_UNKNOWN` | 失败前写操作可能已经完成。 | 重试前先检查结果。 |
| `NETWORK_UNREACHABLE` | 网关无法到达远程端点。 | 只有操作可以安全重复时才重试。 |
| `INTERNAL_FAILURE` | 网关失败，调用方没有安全的补救措施。 | 停止并报告请求 ID。 |

不要把 `ERROR`、`FAILED` 或 `EXCEPTION` 作为主要契约。这些标签把解释负担转给代理，代理会从文字中推断补救措施，而且可能推断错误。

保持代码含义稳定。可以改进面向人的消息，添加 `retry_after_seconds` 字段，或加入新的安全状态值。但不要让 `AUTH_FAILED` 同时表示凭据错误和本地审批拒绝。代理及其外围编排最终会据此分支。

RFC 9457《HTTP API 的问题详情》提供了有用的基础：响应可以包含稳定的问题类型、标题、状态、详情和实例引用。其中团队常常忽略的是它的警告。RFC 指出，`detail` 应帮助纠正问题，而问题详情可能暴露敏感信息。对于代理工具，让稳定的类型或代码成为契约，保持 detail 简短，并使用 instance 或请求 ID 将操作员连接到受保护的证据。

## 请求头和连接错误是证据，不是上下文

团队常把原始请求头和传输消息称为“上下文”。它们其实是证据。证据应放在带有访问控制的审计记录中，而不是代理响应里。

考虑一次失败的 API 请求。典型的 HTTP 库异常可能包含完整的请求 URL、重定向位置、代理地址、响应请求头以及部分响应正文。其中任何一项都可能携带机密。旧式 API 仍可能把 API 密钥放在查询参数中。`Location` 请求头常包含签名下载 URL。Cookie 和自定义身份验证请求头是明显的泄露点。`X-Request-Id` 等不太明显的字段可能没问题，但 `X-Forwarded-Host` 或内部服务请求头可能暴露基础设施，而代理根本不需要知道这些。

SSH 错误也需要同样的约束。不要返回包含私有目标、known-hosts 路径、已提供身份文件或原始主机密钥不匹配文本的命令行。主机密钥不匹配对调用方有明确含义：由于无法验证远程身份，连接被阻止。返回这个含义即可。指纹比较、路径和库诊断信息应保留给操作员。

OAuth 2.0 清楚地说明了这一点。RFC 6750 指出，bearer 令牌会把访问权限授予任何持有它的人，并要求客户端防止令牌在存储和传输中泄露。即使原始请求正确使用了 TLS，错误处理器把 bearer 令牌复制进跟踪记录，也已经违背了这一要求。

应在序列化前清理信息，而不是等日志扩散后再处理。通用日志接收器上的脱敏过滤器可以作为后备措施，但它不是边界。到了这一步，抛出的异常可能已经附加到工具结果、遥测事件或崩溃报告中。

使用字段允许列表，明确哪些字段可以传给代理。拒绝列表最终会漏掉 `x-api-token`、签名查询参数、厂商自定义会话字段或新库属性。允许列表从不披露任何字段开始，只添加已确认有调用方用途的字段。

## 分开说明哪里失败，以及操作是否已经运行

安全的错误设计必须告诉代理远程端是否可能已经执行了操作。这正是大多数重试指导变得危险的地方。

假设代理发送 `POST /deployments`，连接随后超时。网关知道自己尝试过调用，却不知道上游是否收到请求、是否创建了部署，或者响应是否在返回途中消失。返回 `TEMPORARY_FAILURE` 会诱使代理再次发送部署请求。返回 `AUTH_FAILED` 则完全不准确。正确的状态是 `OUTCOME_UNKNOWN`。

响应应明确说明这一点：

```json
{
  "ok": false,
  "code": "OUTCOME_UNKNOWN",
  "message": "The connection ended after the request started. The remote action may have completed.",
  "action": "inspect_outcome",
  "retryable": false,
  "request_id": "act_9b18d4e0",
  "operation": "create_deployment"
}
```

`operation` 表示通用操作类别，而不是完整路径或负载。现在，代理可以使用独立的只读状态调用进行检查，前提是集成提供了这种能力。如果 API 支持幂等引用，网关可以将安全引用与操作关联起来，并在内部查询。若幂等值在目标系统中同时具备访问材料的作用，就不要把它暴露出去。

读取操作也不一定可以安全重试。读取可能触发计费、刷新远程状态，或在看似无害的名称后面运行带副作用的命令。集成作者必须标明某项操作是否可重复，不要要求语言模型仅凭方法名来判断。

在网关中设置重试预算。响应可以包含 `retry_after_seconds: 30` 这样的有限延迟，但前提是上游提供了安全值，或网关自己控制这个限制。不要因为消息写着“暂时”就让代理无限重试。反复失败会制造噪声、消耗速率限额，也会让后续调查更加困难。

## 人员拒绝需要独立的含义

人员拒绝审批并不是远程身份验证错误。它意味着请求的操作没有执行。这个区别同时保护安全性和易用性。

如果工具把审批拒绝映射成 `AUTH_FAILED`，代理可能尝试替代凭据、要求轮换机密，或稍微修改请求后再次尝试。这些行为都没有尊重说“不”的人。如果把拒绝映射成通用内部故障，用户又无法判断是网关出了问题。

返回 `USER_DENIED`，并附上类似“请求的操作未获批准，因此没有执行”的简短消息。如果这些信息不属于工具安全输入契约的一部分，就不要提及机密名称、选中的账户或确切目标。调用方需要停止。人员可以决定是否发起一个新的、可见的请求。

锁定的机密存储又是另一种情况。`VAULT_LOCKED` 表示网关在选择或使用凭据前就拒绝了操作。安全的补救方式是请求人员解锁网关，而不是让代理提供令牌。这样可以避免一种常见故障模式：托管凭据不可用时，模型转而在上下文中寻找其他机密。

Sallyport 直接采用了这种区分：保险库闸门锁定时，它会拒绝所有操作；其逐会话授权还可以区分需要审批的新进程和拒绝已通过身份验证调用的远程服务。代理收到的是操作结果，而凭据始终留在应用的加密保险库中。

不要用更多诊断细节来掩盖审批疲劳。如果用户经常在没有检查的情况下批准调用，应修正审批界面中的操作分组、权限范围和进程身份。更详细的拒绝信息不会让匆忙的同意更安全。

## 建立双记录诊断路径

一份记录应让代理安全使用，另一份记录应足够完整，供获授权的操作员调查。试图让同一份记录同时完成两项工作，最终只会得到模糊的支持体验，或导致机密泄露。

调用方安全记录需要代码、消息、操作、重试指导、请求 ID，也可以包含协议状态。受保护记录则可以包含选中的凭据记录标识符、规范化目标、方法、时间信息、上游响应元数据、经过清理的负载指纹、原始异常以及网关决策轨迹。受保护记录应存放在代理无法通过普通工具查询的位置。

请求 ID 应保持不透明。它应独立于凭据和目标生成。不要在其中编码主机名、暴露活动模式的时间戳，或在你的环境中可能带来问题的递增数据库 ID。这样，用户可以说“请检查 act_9b18d4e0”，却不必接触底层诊断信息。

对于高价值操作，记录网关是否到达每个边界：输入验证、凭据选择、用户授权、连接开始、请求字节发送、响应收到，以及结果返回。这个序列能让操作员在不向代理展示原始请求的情况下，对 `OUTCOME_UNKNOWN` 给出有依据的解释。

这条内部路径还需要防篡改证据。如果有人可以悄悄删除授权失败记录，或改写操作被阻止的原因，审计轨迹就只是一份便利日志。Sallyport 从加密、哈希链式审计日志生成会话和调用视图，`sp audit verify` 可以在密文上离线检查链条。当操作员需要信任记录，却不应获得其内容时，这种方式很有用。

下面是一份受保护记录示例。它刻意不是代理响应：

```json
{
  "request_id": "act_9b18d4e0",
  "event": "http_call_failed",
  "credential_record": "cred_42",
  "destination": "api.internal.example",
  "method": "POST",
  "path_template": "/deployments",
  "bytes_sent": true,
  "response_received": false,
  "exception_class": "ReadTimeout",
  "result_code": "OUTCOME_UNKNOWN"
}
```

即使在这里，也要仔细审查字段。完整路径可能暴露资源标识符。请求正文通常应变成单向指纹、架构名称或严格脱敏的表示。操作员通常需要比较两次尝试，而不是阅读每个提交的值。

## 为错误消息制定明确的脱敏策略

脱敏不是把令牌替换成八个星号。那只能处理你已经认识的机密格式。完善的策略会在字段进入消息、日志、指标和工具结果之前进行分类。

先分类直接机密：bearer 令牌、密码、私钥、Cookie、签名 URL、授权请求头和客户端证书。然后分类敏感上下文：内部 DNS 名称、本地路径、用户名、仓库名称、资源 ID、请求正文，以及暴露部署拓扑的请求头。第二类信息可能适合放进受保护记录，但很少适合出现在代理可见的错误中。

不要返回“尾号为 7KQ2 的凭据被拒绝”。团队加入这类信息，是因为存在多项凭据，操作员想知道哪一项失败了。但这会创建一个可长期保存、可跨跟踪记录关联的标识符。向代理返回 `AUTH_FAILED` 即可。操作员可以通过受保护的请求 ID 查看凭据记录。

默认不要回显工具输入。代理已经知道自己尝试了什么，但网关不能假设输入本身适合再次展示。URL 可能包含签名查询字符串，命令可能包含环境变量赋值，JSON 负载可能包含代理从另一个系统收到的临时凭据。返回类似 `invalid_fields: ["repository"]` 的字段级验证错误，不要复制无效值。

使用带有攻击性的夹具测试脱敏。把机密放进大小写异常、重复请求头、URL 用户信息、百分号编码的查询值、嵌套 JSON、异常原因和 SSH 命令参数中。然后断言，序列化后的工具响应、通用日志、指标标签和崩溃负载中都不会出现夹具机密。只检查成功路径的测试，无法证明失败处理是安全的。

## 把远程消息视为不可信输入

远程 API 可能返回有帮助的错误正文、HTML 登录页面，或专门影响阅读者的字符串。网关不能把这些内容直接放入代理上下文。

这部分涉及机密问题。服务器有时会在错误页面中回显请求值。无效的 Authorization 请求头、Cookie、查询参数或 JSON 字段可能出现在上游诊断响应中。把它传递下去，就会把远程回显变成凭据泄露。

这也是指令完整性问题。如果上游错误写着“运行此命令来修复凭据”，代理可能把它当作操作指导。远程服务无权书写网关的恢复策略。

从上游协议响应中映射已知的安全字段。例如，数字 HTTP 状态码和有文档说明的速率限制延迟可能有用。除非有针对格式的解析器和明确的允许列表，否则应把自由文本正文视为受保护证据。如果集成需要提供面向人的远程原因，应将其规范化为网关自己的语言，例如“服务拒绝了请求的资源”，而不是复制上游原文。

RFC 9110 定义了 HTTP 状态语义，但其中的状态码并不授权你披露源站响应正文。要清楚地区分两者。协议信息可以帮助恢复，任意上游文本却不是安全的诊断契约。

## 为失败场景测试契约

大多数团队会测试有效请求是否返回有用数据，也应测试每个无效请求和中断请求是否只返回代理应该看到的数据。

把错误架构作为有版本的契约。通过测试验证它，并在开发期间于序列化边界拒绝未知字段。对象拥有严格结构时，允许列表更容易审查。

使用足够特别的夹具机密，以便发现意外转换，然后在每个阶段强制制造失败。测试矩阵应覆盖凭据处理前的输入验证、锁定的保险库、被拒绝的同意、远程 401 和 403 响应、速率限制、DNS 失败、TLS 验证失败、字节离开前的超时、字节离开后的超时、格式错误的上游 JSON，以及网关自身抛出的异常。

每种情况都应断言四点：

- 工具响应包含预期代码和允许的操作。
- 任何调用方可见的序列化字段中都不包含夹具机密。
- 响应不包含原始请求头、原始上游正文或本地连接细节。
- 受保护的诊断记录带有请求 ID，并包含足够的状态供操作员调查。

不要只满足于正则表达式检查。搜索确切的夹具机密、其 URL 编码形式、在相关情况下的 base64 形式，以及常见前缀或后缀。每次更新 HTTP、SSH、遥测或崩溃报告依赖项后，还应手动检查一份捕获的错误响应。库可能在未经你许可的情况下改变异常格式。

真正困难的地方，是抵制让代理错误看起来像操作员调试控制台的冲动。让调用方契约保持小巧、稳定，并围绕行动组织。让证据受到保护，并通过关联 ID 连接起来。下一次故障到来时，这种分离会让代理拥有足够信息来安全行动，也让人拥有足够信息来修复真正的问题。
