# 锁定保险库中的待处理请求应该等待吗？

到达锁定保险库的代理请求应该当场失败。它不应变成一条休眠命令，等有人一小时后解锁保险库时又突然恢复执行。

只有当你认真思考解锁究竟证明了什么，这条规则才会显得严格。解锁只能证明某个人此刻授权访问机密存储。它不能证明旧代理进程仍在执行同一项任务，不能证明某次部署仍然值得进行，也不能证明拉取请求没有变化，更不能证明旧的 HTTP 请求体和 SSH 命令仍然合理。

这类决定常常被伪装成便利功能。有人看到一次被阻塞的运行，就会提议建立队列：保留请求，显示通知，等用户通过 Touch ID 后再释放任务。请求已经形成，所以队列看起来很有帮助。这也正是它危险的原因。一个已经形成的请求，已经从计划跨过了界线，变成等待授权执行的操作。

更安全的规则很简单：保险库锁定时，拒绝操作，并丢弃其中可分发的部分。解锁后，代理可以检查当前状态并提交新的请求。这个新请求可以基于当前上下文获得会话授权，或按调用单独获得批准。

## 锁定的保险库必须立即拒绝操作

保险库锁定是一道访问边界，不是暂时的网络故障。把它当成故障处理，会促使系统自动重试，而旧代理意图最不应该触发的正是自动重试。

当代理请求调用 API 或建立 SSH 连接时，网关已经掌握了判断请求是否可以继续所需的信息。如果保险库处于锁定状态，它不需要读取请求体、解析主机、启动连接，也不需要等待用户。它应在接触外部世界之前返回拒绝结果。

返回结果需要告诉代理发生了什么，同时不能诱导代理盲目重放。可以采用这样的结构：

```json
{
  "ok": false,
  "error": {
    "code": "VAULT_LOCKED",
    "message": "The credential vault is locked. This action was not queued or sent.",
    "request_id": "req_7d4c1f",
    "retryable": false
  }
}
```

`retryable: false` 看起来可能违反直觉。代理之后可以发起新的请求，但失败的这个请求本身不安全，不能重试。区分这两者，可以避免客户端作者编写通用退避循环，把一次解锁变成一批未经重新审查的旧任务。

不要用 `503 Service Unavailable`、超时或通用传输错误来代替它。这些响应会让善意的客户端再次执行完全相同的操作。网关需要返回有明确语义的结果，告诉客户端：“人为控制的安全条件阻止了这次调用，而这次具体调用已经结束。”

这条规则同样适用于读取和写入。团队常常只对写操作保持警惕，因为过时的写入可能删除或部署某些内容。过时的读取同样可能暴露客户信息、泄露生产环境配置，或让代理根据用户已经不想获取的数据做出后续决定。

## 解锁并不等于批准早先的意图

解锁事件和操作批准回答的是两个不同的问题。解锁是在问，保险库现在是否可以使用其中的机密。操作批准则是在问，这个进程是否可以在当前任务下执行这类具体的外部调用。

把这两个决定合并，会产生隐蔽的授权漏洞。想象一下，代理准备了一条重启服务的 SSH 命令。开发者合上笔记本电脑，保险库锁定，代理于是发送调用。四十分钟后，开发者回来解锁 Mac，只是为了检查另一个问题，网关却因为保留着旧命令而触发了重启。开发者当时并没有批准重启，他们只是为自己恢复了保险库访问权限。

类似问题也会以更不明显的形式出现：

- 代理准备发布拉取请求评论，但审查者已经处理了相关问题。
- 代理准备调用云 API 时使用了一个分支名，但该分支现在已经指向另一个提交。
- 代理准备查询支持系统，但当初支持请求已经结束。
- 代理准备发布软件包，但保险库锁定期间出现了测试失败。

常见的反驳是，请求在锁定之前已经获得授权。有时确实如此，但这仍不足以证明可以延迟分发。只要进程还存在，会话授权可以持续一段时间。意图却不会因为一串字节留在队列中就自动持续。

因此，网关应清楚地区分：

1. 保险库锁定决定任何依赖机密的操作是否可以开始。
2. 会话授权决定这个代理进程是否因当前运行而被认可。
3. 单次调用批准决定标记为需要逐次确认的凭据现在是否可以使用。

如果第一个决定是否定的，就停止。不要继续评估后面的决定，也不要保留一条可以在答案改变后直接发送的请求。

## 通知不是请求队列

你可以通知某个人任务被阻塞，同时不保留任何可以执行的任务。这是两种不同的设计，只是团队常常把它们都叫作“待处理请求”。

**通知**是一条不可执行的事实。它可以说明某个进程在特定时间，尝试使用某个凭据引用访问某类目标。它帮助用户决定是否要解锁并回到任务中，但不能重建请求头、请求体、SSH 命令或批准令牌。

**请求队列**则保留了足够晚些时候分发的材料。它可能包含 HTTP 方法、URL、请求体、命令参数、凭据选择、授权决定或签名的重放令牌。一旦保留这些材料，你就已经建立了延迟执行机制。

这个区别在实现上很重要。下面这样的记录可以接受，作为被阻塞工作的通知：

```json
{
  "event": "action_denied",
  "reason": "vault_locked",
  "session_id": "ses_31b8",
  "channel": "ssh",
  "credential_label": "production-deploy",
  "destination": "deploy host",
  "occurred_at": "2026-07-22T21:14:05Z"
}
```

但如果系统之后可以从该事件中恢复完整命令、目标地址、私有请求体或凭据使用授权，就不能接受。记录应支持调查，而不是支持重放。

对摘要也要谨慎。摘要通常适合用于关联查询，但前提是网关不能利用它取回保存的请求内容。摘要加上隐藏的数据块，仍然是一条队列。只存在于追加式审计记录中的摘要则不同。

这里很容易出现产品诱惑：待处理请求列表会让仪表板看起来很有响应。除非每一项都要求代理在用户操作后重新提交新的调用，否则应当抵制这种设计。提供“解锁后全部运行”按钮的界面，实际上已经把安全提示变成了延迟任务调度器。

## 给代理一个不会误解的状态机

当网关提供小而明确的状态模型时，代理的行为会更可靠。含糊的错误会让代理自行编写恢复方案，而它设想的方案可能是等待、重试，或寻找另一条凭据路径。

可以使用这样的状态机，让请求在保险库门禁拒绝后立即进入终止状态：

```text
received
  |
  +-- vault locked --> denied_locked (terminal)
  |
  +-- vault unlocked --> session check
                           |
                           +-- not approved --> denied_session (terminal)
                           |
                           +-- approved --> per-call check
                                             |
                                             +-- approval declined --> denied_call (terminal)
                                             |
                                             +-- approved --> dispatched --> completed
```

重点不在图表本身，而在于 `denied_locked` 没有通往 `dispatched` 的箭头。之后的新请求可以从 `received` 进入，但旧请求不能从任何位置重新进入。

这种设计也让幂等性更容易理解。如果调用因为保险库锁定而失败，不要像服务器已经接受该操作一样预留幂等令牌。代理之后必须构建另一条请求，并使用新的请求 ID。如果外部 API 支持幂等键，新提交的请求可以使用反映业务操作意图的应用层键，但网关的拒绝不应创建一条半完成的操作记录。

对于写操作，只要目标系统支持，就让代理携带当前的前置条件。这可以是修订 ID、实体版本、预期的分支头或 ETag。保险库打开后，代理应重新获取当前上下文，再构建能够反映当前状态的调用。过时的请求无法通过正确的前置条件，而新请求至少能证明代理重新查看过当前状态。

不要仅根据经过的时间推测新鲜度。变化快速的部署中，五秒就可能太久；静态查询等待一小时也可能没有影响。新鲜度来自重新读取任务状态并重建操作，而不是来自计时器。

## 批准必须绑定实际调用

解锁后重新提交的新请求仍需要严格的批准模型。否则，你只是移除了延迟重放，又换成了一项含糊的权限，让代理在用户点击之后改变主意。

批准卡应绑定会改变操作安全含义的材料。对于 HTTP，通常包括方法、规范化后的目标地址、凭据引用和请求体摘要。对于 SSH，则包括主机身份、账户、命令或命令摘要以及凭据引用。批准还应绑定代理进程，并快速过期。

不要使用只写着“允许代理访问生产环境”的批准卡。这样会让用户批准一个类别，而细节却由代理掌控。用户可能愿意让某个进程读取一个端点，却不愿意让它在同一主机名下调用管理端点。

实际的批准记录可以是这样：

```json
{
  "approval_id": "apr_8c62",
  "session_id": "ses_31b8",
  "process_identity": "signed-authority-and-process-instance",
  "channel": "http",
  "credential_label": "billing-api",
  "method": "POST",
  "destination": "api.example.internal/v1/invoices",
  "payload_digest": "sha256:...",
  "expires_at": "2026-07-22T21:16:00Z",
  "used": false
}
```

对于很大的请求体，网关不必显示每一个字节，也可以诚实地说明批准的具体内容。但它必须把批准绑定到实际发送的字节。简洁的人工摘要可以和摘要值一起显示，而摘要值则保护确切请求，防止内容被替换。

对需要逐次批准的凭据，使用一次性批准记录。在开始分发前将批准标记为已使用，而不是等响应返回后再标记。如果分发后连接中断，代理可能需要检查目标系统来判断操作是否已经生效。重复使用批准会让重复写入更容易发生。

## 应测试在错误时机解锁的情况

最能说明问题的测试不是“锁定时调用会失败吗？”而是“用户解锁前外部世界发生变化时，会怎样？”

准备一个无害的测试服务，其中一个端点记录部署目标，另一个端点修改当前允许的目标。然后执行以下步骤：

1. 启动一个计划发送 `POST /deploy` 的代理任务，请求体为 `{\"revision\":\"a1b2c3\"}`。
2. 在代理发送调用前锁定保险库。
3. 确认网关返回 `VAULT_LOCKED`，并且测试服务没有收到任何内容。
4. 在保险库仍然锁定时，将允许的修订版本改为 `d4e5f6`。
5. 因为无关的原因解锁保险库。
6. 不操作代理，等待一段时间。

正确结果很普通：测试服务仍然收不到任何内容。如果它收到了针对 `a1b2c3` 的部署，说明网关存在延迟执行路径。

接着告诉代理调用已被拒绝，让它重新读取允许的修订版本，然后提交新请求。此时预期请求应为 `d4e5f6`，网关也可以根据当前规则要求会话授权或单次调用批准。这样就能证明恢复路径保留的是当前上下文，而不是把锁定期间当成一个隐形的暂停按钮。

对 SSH 运行同样的测试。使用一条命令，写入包含目标修订版本的无害标记。不要只测试建立连接。危险的实现往往会在选择密钥后保留命令，等保险库可用时再分发。你需要证明命令文本本身也会在锁定边界处失效。

## 不要让客户端隐藏拒绝结果

网关即使做出了正确决定，如果客户端把所有错误都压平为“稍后重试”，仍然会产生糟糕的行为。协议需要提供足够的结构，让代理框架和包装脚本能够有意识地处理保险库锁定。

响应契约应向代理传达三点。第一，操作没有离开网关。第二，网关已经丢弃了请求。第三，代理不应自动重试同一请求。

代理自身的恢复循环应更接近这样：

```text
if result.error.code == "VAULT_LOCKED":
    record_blocked_task()
    ask the user to unlock when appropriate
    stop this action

if user later resumes the task:
    reread relevant state
    decide whether the action is still needed
    create a new request
```

其中 `decide whether the action is still needed` 很重要。不能把它替换成 `retry request`。代理可能已经收到新的用户指令、编辑了文件、切换了分支，或得知测试失败。此时它可能需要执行不同的操作，或者什么都不做。

对于无人值守运行，应将拒绝结果返回编排器，让这次运行以阻塞状态结束。不要让网关等待某人解锁。等待中的代理进程会占用内存，保留可能变得敏感的上下文，也会制造压力，让人把未来的解锁当成继续运行的许可。干净地停止运行，才能让人先审查任务，再决定是否恢复。

如果界面显示通知，措辞应当准确：“代理操作因保险库锁定而被拒绝。”不要使用“继续”或“批准待处理请求”这样的按钮。按钮可以打开保险库或显示会话详情，但不应让旧操作运行。

## 日志应证明没有发送任何内容

拒绝结果值得写入审计记录，因为运营人员最终会问：代理只是尝试执行了操作，还是确实联系了外部系统？

记录尝试使用的通道、代理运行的身份、凭据引用、规范化后的目标地址、结果和时间戳。明确标记分发状态。运营人员应能区分 `denied_before_dispatch`、`dispatch_started`、`remote_rejected` 和 `completed`，而不必从异常字符串中猜测发生了什么。

默认不要记录机密、原始授权请求头、私钥材料或完整请求体。对于敏感请求体，可以记录摘要，并在系统能够避免泄露内容时，记录一段经过批准的简短摘要。目标是确认发生了什么，而不是再建一份保险库原本要保护的数据副本。

审计查询应能回答这样的事故报告：

```text
21:14:05  session ses_31b8 attempted SSH action using production-deploy
21:14:05  vault gate denied action before dispatch
21:15:41  vault unlocked by local user action
21:16:09  no action dispatched from ses_31b8
```

最后一行可以根据没有分发记录推断出来，但明确的终止状态能加快调查并减少歧义。如果使用哈希链式日志，也应在事故审查期间验证链条，而不只是进行日常检查。如果记录真正重要时没有人使用防篡改证据，它的价值就很有限。

Sallyport 将运行记录放在 Sessions 日志中，将单次调用记录放在 Activity 日志中，这种分离很适合这个问题，因为锁定保险库导致的拒绝既属于运行历史，也属于操作级追踪。它的离线 `sp audit verify` 检查还可以让团队在不打开保险库的情况下验证历史记录的完整性。

## 便利队列会制造第二套授权系统

一旦网关开始保存请求并等待之后释放，它就会逐渐积累各种规则：请求保留多久、谁可以释放、原始进程是否必须继续存在、内容能否改变、重启后如何处理，以及一次解锁释放一个请求还是全部请求。

这些规则其实是一套伪装起来的策略引擎。它们很难向用户解释，因为每个例外都会改变解锁的含义。缩短队列超时时间无法解决这个含义问题。要求原始进程继续运行也无法解决，因为进程可能已经被入侵，或者只是继续使用过时的上下文。

让设计保持简单。保险库门禁锁定时拒绝所有操作。新的进程会话可能需要授权。标记为逐次批准的凭据每次都需要明确确认。其他便利功能都放在代理一侧，作为任务恢复机制处理。这样代理必须重建计划，用户也能看到发生了哪些变化。

这还会给用户建立一种可靠的习惯：解锁只恢复考虑新操作的能力，不会释放那些用户已经忘记正在等待的操作。人们可以依据这种心智模型做出稳妥决定。当锁屏同时变成隐藏的工作队列时，用户就很难判断系统会做什么。

## 让安全路径比不安全路径更省事

团队之所以建立队列，是因为在日常开发中，直接失败可能显得很打断流程。解决这种摩擦，但不要保留可执行请求。

让会话授权的有效范围与代理进程生命周期一致，这样开发者不必批准每个普通调用。对于生产管理或外部发布等值得谨慎处理的凭据，再要求逐次确认。返回清晰的拒绝结果，让代理可以用普通语言报告被阻塞的工作。为用户提供解锁、检查代理会话并有意识地恢复任务的方式。

同时明确代理指令。告诉代理，凭据始终留在其上下文之外，锁定保险库的结果会结束当前尝试，之后的操作必须在检查当前状态后重新构建。代理提示词不能强制执行规则，但可以减少无意义的重试，也让协议行为更容易使用。

测试这种设计很简单。如果一个人在分心、疲惫或处理无关任务时解锁保险库，任何早先的代理操作都不应发生。如果做不到这一点，就在它变成事故报告之前移除队列。
