# 审批请求绑定可防止点击批准后的更改

只有当人工审批授权的是一个之后无法改变的操作时，这次审批才有意义。先展示代理拟发送的 HTTP 请求或 SSH 命令，再让同一个代理稍后提交最终细节，会留下足够宽的检查时机到使用时机间隙，足以让破坏性请求穿过网关。

我见过这种错误披着令人安心的外衣出现：漂亮的审批单、进程身份徽章、绿色确认状态。随后，实现却把客户端持有的审批令牌通过协议传回来，并接受令牌旁边附带的新 URL 或命令。用户批准的是一件事，执行器做的却是另一件事。即使没有人有意绕过审批，这仍然是失败的授权设计。

## 预览必须描述不可变的授权对象

审批界面必须渲染一个已保存的操作对象，执行器在审批后也必须分发同一个对象。界面展示的是权威状态，而不是要求客户端更认真地重复一次自己的提案。

在显示任何审批界面之前创建这个对象。为它生成随机操作标识符，将其与请求进程和会话关联，解析特定通道的输入，并由执行器控制，将它保存在内存或加密本地存储中。客户端可以收到待处理状态和标识符，但不应收到一种能力，允许它之后再附加新的执行参数。

可以把它想成餐馆的点菜单，而不是一张许可单。服务员把订单标记为已批准后，厨房不会要求顾客重新描述一遍晚餐，而是准备手里的那张订单。如果订单发生变化，厨房会收到一张新订单，顾客也能看到变化。

不可变对象包含的内容不应局限于紧凑预览中可见的字段。它还需要进程身份、会话标识符、创建时间、过期时间、通道、凭据引用、规范化后的操作细节和绑定摘要。摘要可以让你发现意外变更和蓄意替换，但真正承担安全工作的，是执行器对该对象的所有权。

因此，审批响应只应表示：“批准带有绑定摘要 sha256:... 的操作 7c91...”。它不应表示“这个进程可以执行一次 HTTP 调用”，也不应表示“调用方稍后可以执行命令 X”。这些更宽泛的声明可以用于会话授权，但不能替代对某个具体副作用的同意。

这种区别在自主代理场景中尤其重要，因为客户端不是一个值得信任、会忠实抄录人工决定的文员。它是一个活跃的程序，可能会重试、根据工具结果组合后续调用、改变计划，或者只是包含一个 bug。预览后仍然能被客户端影响的每个字段，都应视为可疑。

## HTTP 预览必须列出每个实际生效的输入

HTTP 审批需要绑定方法、完整目标地址、实际生效的请求头、请求体，以及可能改变请求含义的分发行为。只显示“向账单服务发送 POST”这类内容，给用户的信息远远不够，无法批准一次会改变状态的调用。

RFC 9110 将请求方法、目标、字段和内容分开定义，因为服务器可能分别赋予它们不同的含义。授权代码也应保持这种区分。不要为审批单写出一句好看的总结，然后让较低层级去补齐那些不方便展示的部分。

对于出站请求，应在解析模板和默认值后捕获以下内容：

- 协议、主机名、端口、路径和查询字符串。
- HTTP 方法，以及请求是否可以跟随重定向。
- 每个实际生效的请求头名称和值，但保险库注入的秘密材料除外。
- 最终请求体字节、内容类型、长度和摘要。
- 凭据引用和注入模式，例如 bearer 或自定义请求头。

凭据值不应出现在预览或代理上下文中，但用户仍需要足够的信息来判断它的使用方式。“仅作为 bearer 令牌，将生产环境支付凭据发送到 api.example.test”，与“已附加凭据”表达的是完全不同的情况。执行器可以根据保险库记录生成前一种描述，同时不暴露令牌。

不要审批尚未展开的模板。假设代理提出 `POST /users/{id}/role`，请求体中包含 `{role}`。如果另一个组件在审批后才展开这些变量，它就可能改变实际目标或权限。应先解析变量，再创建操作对象。如果某个输入确实要到稍后才能获得，就在它变得具体的那一刻请求审批。

请求头需要比大多数团队想象中更多的关注。重复请求头、变化的 `Content-Type`、新增的覆盖请求头，或不同的查询编码，都可能改变服务器行为，却让友好的摘要看起来完全一样。如果协议或目标服务器赋予重复字段顺序以含义，就按顺序保留它们。对于有歧义的输入，应直接拒绝，不要静默地用逗号拼接。

内部数据结构可以采用类似下面的形式。这是实现模式，不是端点契约：

```
{
  "action_id": "7c91e2d4",
  "binding": "sha256:4f06...",
  "caller": {"session": "p-481", "process_start": "..."},
  "http": {
    "method": "PATCH",
    "url": "https://api.example.test/v1/users/42",
    "headers": [["content-type", "application/json"]],
    "body_sha256": "a4d8...",
    "body_length": 31,
    "redirects": "deny"
  },
  "credential_ref": "vault:payments-prod"
}
```

用户批准这个对象后，传输层会从自己保存的记录中读取 `http` 和 `credential_ref`。它不会接受客户端提供的第二个 URL、请求头映射或请求体。最后这条规则才是防止 bug 的关键，摘要只能证明审批对应的是哪条记录。

## SSH 命令需要绑定解析后的执行上下文

SSH 命令预览必须绑定远程执行上下文，因为仅凭命令字符串，用户无法知道实际会在哪里运行。同样的文本，在不同的用户、主机、shell、工作目录、环境或标准输入流下，可能产生不同的结果。

RFC 4254 定义了用于请求执行命令的 SSH 连接协议，但它并没有建立面向用户的审批边界。SSH 会把命令字符串传给服务器。远程 shell 解释、账户配置、强制命令和命令包装器，都可能增加基础预览没有展示的含义。

保存并展示主机名、端口、远程用户、主机身份验证预期、身份引用、确切的命令字节、执行模式、终端分配情况、工作目录、提供的环境，以及标准输入的摘要和长度。如果文件操作是通过 SSH 辅助程序执行的，还要绑定远程路径、操作类型、文件摘要和任何覆盖行为。

执行模式不是表面细节。下面两种写法并不等价：

```
/usr/local/bin/deploy --environment=staging
sh -lc '/usr/local/bin/deploy --environment=staging'
```

第一种是在辅助程序支持的情况下请求直接执行。第二种明确要求 shell 解析字符串。shell 解析会引入展开、命令替换、shell 启动行为和引号规则。如果产品同时接受两种模式，应清楚标注，并且绝不能在审批后把一种转换成另一种。

不要从适合展示的命令字符串创建预览，却执行另行组装的参数数组。显示字符串可能隐藏空参数、换行符、不可打印字符，或者掩盖 `--file=/tmp/a b` 与两个参数之间的差异。应根据辅助程序实际使用的确切字节序列或参数向量，生成安全的转义表示。如果命令包含无法安全显示的字节，就拒绝它，或显示用户可以检查的明确编码形式。

远程目标的变化需要遵循与 HTTP 重定向相同的原则。本地 SSH 配置别名、代理跳转或主机配置查询，都可能把一个简短的主机名解析成不同的目标。在审批前解析最终连接计划并将其绑定。如果 DNS 重绑定属于威胁模型的一部分，要谨慎增加独立的主机地址策略。固定地址可能破坏正常故障转移，而忽略恶意解析器又可能把一个有效主机名指向错误的机器。不要假装命令预览能解决这个独立问题。

## 用户点击后，漏洞窗口就开始了

当界面检查拟议细节、记录用户同意，而后续执行路径又从客户端读取可变细节时，问题就出现了。它经常藏在普通的异步代码中。

考虑一个要求网关转账的代理进程：

1. 客户端发送 `POST https://api.example.test/transfers`，请求体金额为 50 个单位。
2. 网关创建预览，等待用户批准。
3. 用户阅读目标地址和金额后批准。
4. 客户端发送 `execute`，附带审批标识符以及金额为 5,000 个单位的新请求体。
5. 网关只检查审批标识符有效，注入凭据，然后发送新的请求体。

这个过程不需要被攻破的界面，也不需要被盗取的凭据。客户端使用的是网关主动提供的 API。只批准原始请求并断言标识符有效的测试会通过。比较最终出站字节与获批对象所表示的字节的测试会失败，而这正是你希望看到的结果。

当网关通过引用保存可变请求对象时，也会出现同样的问题。预览工作线程读取它，代理侧重试处理程序修改它，分发工作线程随后发送修改后的版本。仅仅复制对象并不能彻底解决问题，因为嵌套映射、请求体缓冲区或回调仍可能共享。应构建一个不包含任何客户端所有引用的不可变值，或者将它序列化为权威表示，并且只从该表示重建。

过期无法修复可变状态。一个持续三十秒的审批，仍可能在第一秒授权一个被改过的请求。速率限制也无法修复这个问题。它只能降低客户端利用错误的频率，不能改变用户是否批准了这个效果。

让状态机保持足够小，便于审计：

```
proposed -> frozen -> awaiting_human -> approved -> dispatched
                         |                 |
                         v                 v
                      rejected           expired
```

只有从 `approved` 到 `dispatched` 的转换可以执行外部操作。这个转换必须根据操作标识符加载冻结记录，重新检查调用方和过期时间，然后直接把记录交给 HTTP 或 SSH 执行器。任何修改细节的请求都应回到 `proposed`，并创建新的标识符。

## 当语义可能漂移时，绑定实际字节

绑定需要稳定的表示形式，否则两个组件可能都认为自己批准了同一个请求，实际构造出的线路数据却不同。真正困难的是决定哪种表示应当拥有权威地位。

对于简单的 JSON 请求，应按照有文档说明的规则规范化结构化形式，序列化一次，并保留最终请求体字节及其摘要。预览可以展示易读的 JSON，但分发路径必须发送保留的字节。除非变换后的输出本身就是获批对象，否则不要在审批后解析、美化并重新序列化。

对于表单、multipart 上传、重复请求头或任意二进制输入，字节通常是更安全的边界。绑定确切字节、内容类型和会影响服务器解释的传输成帧决定。摘要能让界面识别大请求体，而无需把私密文档完整放进审批单，但执行器必须保留这些确切字节，或从受信任状态中重新生成它们。

URL 规范化也是一个陷阱。统一主机名大小写通常没有问题。解码并重新编码路径、对查询参数排序、删除空值，或把 `+` 和 `%20` 视为相同，都可能改变应用路由请求或验证请求的方式。应选择定义范围狭窄的规范形式，记录规则，并拒绝存在多种合理解释的输入。“我们会规范化 URL”不是安全属性。

对于 SSH，如果协议辅助程序可以在不调用 shell 的情况下执行参数向量，那么参数向量优于 shell 字符串。应将每个参数绑定为独立的字节序列。当远程端必然接收一条 shell 命令字符串时，要精确绑定该字符串，并如实展示其转义形式。不要声称本地机器上的解析器可以证明任意远程 shell 启动文件会做什么。

审批摘要应覆盖一个版本化、域分离的编码。给编码数据加上固定标签，例如 `action-v1/http` 或 `action-v1/ssh`，使用明确长度或确定性序列化器包含所有不可变字段，然后进行哈希。版本化可以防止升级后旧解释悄悄变成新解释。域分离可以防止 HTTP 对象仅仅因为序列化字节碰巧相同，就与 SSH 对象匹配。

## 调用方身份和操作绑定解决的是不同问题

每会话授权告诉你哪个正在运行的进程可以请求操作。操作绑定告诉你该进程究竟可以执行哪个操作。两者都需要，谁也不能替代谁。

应将操作绑定到网关观察到的进程身份，而不是客户端自行发送的名称。在 macOS 上，这可以包括进程代码签名机构、进程标识符和进程启动实例。启动实例很重要，因为操作系统可能会重新使用进程标识符。如果进程退出，就连同会话权限一起丢弃它尚未完成和已经批准的操作。

不要因为子进程知道父进程的标识符，就让它继承宽泛的审批。应在网关边界观察每个连接进程。如果工具架构无法做到这一点，就明确说明限制，并缩短审批有效期，不要假装拥有不存在的信心。

人工审批也需要短暂且有界的有效期。过期时间应属于冻结对象和审批决定，而不只是界面内部的计时器。过期后，即使客户端一直保存着旧响应，执行器也必须拒绝分发。被撤销的会话同样应阻止其任何已批准但尚未使用的操作继续分发。

Sallyport 的每会话授权可以确定新连接的代理进程是否有权使用某个运行实例，但操作网关仍然需要为每个预览过的副作用建立这个独立的不可变边界。会话决定有意保持宽泛，而 HTTP 请求或 SSH 命令审批必须保持精确。

不要因为进程身份可信，就减少展示的细节。受信任的签名代码也可能存在缺陷，遇到意外的提示注入输入，或包含走上作者未曾预料分支的插件。人工网关的价值正在于，它能在具体效果离开机器前让用户看见它。

## 重试和重定向必须留在获批边界内

只有在重新发送同一个冻结操作，并且条件已被记录时，传输重试才可以复用审批。许多重试实现会从客户端可变状态重建请求，悄悄违反这条规则。

冻结操作时就定义允许的重试行为。例如，在收到任何 HTTP 响应之前发生连接失败时，允许使用相同的 URL、请求头、请求体字节和凭据引用重新发送一次。每次尝试都记录在同一个操作标识符下。除非协议和目标 API 提供可靠的幂等机制，否则不要在响应不确定的情况下重试非幂等请求。

幂等令牌只有在自身也被绑定时才有用。如果由执行器生成，就在冻结操作时生成，并将其保留在获批的请求头集合中。如果由客户端提供，就把它视为获批请求的一部分。重试时替换令牌，可能让一次审批产生第二个副作用。

身份验证恢复同样需要警惕。如果保险库在内部刷新凭据，但仍向同一个目标地址发送完全相同的获批请求，操作可能仍然处在边界内。如果恢复过程改变了租户、目标地址、权限范围，或应用可见的请求头，那就是不同的请求，需要重新审批。避免让后台机制把一次授权失败变成未经审核的备用调用。

获批操作的默认 HTTP 重定向行为应为拒绝。即使重定向保留了所有相关字段，也可能跨到另一个权限主体。如果产品支持跟随重定向，就在分发前检查每一跳；当主机、端口、方法或请求体发生变化时，要求创建新的操作对象。对于同一权限主体上的相对重定向，应按照明确规则处理，不要任由底层客户端库自行决定。

SSH 重连遵循相似的规则。只能使用已绑定的主机计划、身份和命令进行重连。新发现的主机指纹、变化的代理路径或变化的主机解析结果，根据你的威胁模型，可能需要新的人工决定。重试代码应足够简单，让审计员一眼看出它不可能扩大原来的审批范围。

## 将审批和执行分别记录为事件

审计记录必须同时保留用户查看过的操作，以及执行器尝试过的操作。只记录最终请求，无法证明预览是否与它匹配；只记录审批，也无法证明是否发生过分发。

记录冻结操作的标识符、绑定摘要、调用方身份、适合展示的预览字段、凭据引用标识符、审批时间、过期时间和结果。然后记录每次分发尝试，包含相同的标识符、传输结果、重试原因，以及实际发送的请求体或命令摘要。用户可见日志和代理响应中都不要保存秘密值。

这正是哈希链日志发挥作用的地方。当验证覆盖保存的序列时，哈希链可以发现之前日志条目的删除或重新排列。但它无法让含糊的事件变得有用。如果记录只有“已批准 SSH 操作”，哈希链只会永远忠实地保存这条含糊事件。

对于维护写入盲加密哈希链日志的网关，离线验证器应独立检查密文链，不依赖保险库访问权限。Sallyport 通过 `sp audit verify` 提供这项检查，因此调查人员无需先打开凭据保险库，就能验证日志连续性。

审计输出应让绑定关系清晰可见。审查人员应该能看到审批 `7c91e2d4` 覆盖的是摘要 `4f06...`，分发使用的也是同一个摘要，而执行器拒绝了任何不匹配的提案。不要在审批时打印一份人工摘要、执行时打印另一份原始请求，却没有持久化的关联关系把两者连接起来。

## 假设客户端会反悔，并据此测试

普通集成测试能证明获批操作会成功。安全测试必须证明获批操作无法变成另一个操作，即使用户点击后客户端表现得很糟糕。

构建一个在审批完成后立即暂停的测试工具。分发等待期间，逐一修改每个由客户端控制的输入：方法、主机、端口、路径、查询参数、请求头值、重复请求头、请求体、凭据选择器、重定向设置、SSH 用户、命令、环境、标准输入和文件摘要。执行器应当分发原先保存的操作，或拒绝这次尝试，绝不能分发被修改的版本。

要像测试字段变化一样有意测试竞争条件。针对一个已批准的标识符发送两条 execute 消息。在同一时刻取消审批并发送 execute。让请求进程退出，再让新进程连接并尝试使用旧标识符。在审批等待期间重启界面。把待处理操作存储填满，直到触发过期清理。这些都是普通的生命周期事件，而授权代码经常在这些事件之间的接缝处失败。

使用一个能报告确切方法、目标地址、重复请求头和所收到请求体摘要的虚拟 HTTP 服务器。使用一个受控 SSH 端点，记录远程用户、命令字节、终端请求、环境和标准输入摘要。断言应基于这些实际观察，而不只是网关的意图对象。测试的重点，就是捕获意图与传输之间的偏差。

最后，让失败测试易于理解。有用的失败信息应说明审批摘要 `4f06...` 覆盖的是 `PATCH /v1/users/42`，而尝试分发时带有不同摘要，因此被拒绝。含糊的“授权失败”会让工程师回到到处打印日志的老路上，并诱使他们不断削弱检查，直到测试变绿。

## 将不可变边界放在执行器旁边

持有凭据并打开 HTTP 或 SSH 连接的组件，必须拥有冻结的获批操作。任何更早的边界都会给其他层留下重新解释、重建或替换操作的机会。

这种设计也能让人工交互保持真实。用户看到的是根据即将发送的对象生成的预览，代理收到的是结果而不是凭据，审计记录则用一个标识符连接意图、审批和执行。这些是彼此独立的好处，但都依赖于同一条原则：拒绝点击后的修改。

如果当前流程会把审批令牌返回给代理，并在之后接受新的操作字段，请先修复这一点，再考虑改进文案、增加策略控制或添加另一个审批对话框。先冻结请求，再让执行器证明自己使用的是冻结记录。
