阅读需 8 分钟

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

锁定保险库中的待处理请求需要明确的失败路径、解锁后的最新用户上下文、严格的重试规则,以及能够解释每次拒绝的审计记录。

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

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

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

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

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

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

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

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

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

{
  "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、请求体、命令参数、凭据选择、授权决定或签名的重放令牌。一旦保留这些材料,你就已经建立了延迟执行机制。

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

{
  "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"
}

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

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

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

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

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

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

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。保险库打开后,代理应重新获取当前上下文,再构建能够反映当前状态的调用。过时的请求无法通过正确的前置条件,而新请求至少能证明代理重新查看过当前状态。

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

批准必须绑定实际调用

在保险库边界阻止操作
Sallyport 的硬件控制保险库锁定时,会拒绝所有依赖机密的操作。

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

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

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

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

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

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

验证代理做过什么
无需打开保险库,即可使用 sp audit verify 离线验证哈希链式审计历史。

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

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

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

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_dispatchdispatch_startedremote_rejectedcompleted,而不必从异常字符串中猜测发生了什么。

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

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

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 检查还可以让团队在不打开保险库的情况下验证历史记录的完整性。

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

让 HTTP 机密留在外部
通过凭据注入发送 HTTP 请求,不把 bearer token 或自定义请求头机密交给代理。

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

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

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

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

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

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

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

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

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

常见问题

AI 代理的请求应该等到保险库解锁吗?

把锁定的保险库视为立即拒绝的边界。网关应返回机器可读的锁定结果,并丢弃可执行的请求,而不是把它保存起来等待之后执行。代理只有在获得最新上下文并决定再次发起调用后,才能重新请求。

为什么解锁保险库不应自动运行排队的代理操作?

不应该。解锁只能证明人可以再次访问机密,不能证明之前的请求仍然符合意愿。保险库关闭期间,时间、代码仓库状态、用户意图和代理计划都可能发生变化。

保险库锁定时,网关应该返回什么错误?

为原始请求使用独立错误,例如 VAULT_LOCKED,并设置 retryable: false。向代理提供简短说明和用于排查的请求 ID,但不要保留带凭据的操作供之后重放。

保险库打开后,代理可以安全地重试请求吗?

通常不应该直接重试。只读调用也可能在用户已经转移注意力后泄露数据,写操作则可能造成重复或过时的变更。代理应在获得最新上下文后,自行判断是否要创建新的请求。

设置较短的过期时间,就足以让排队请求安全了吗?

过期时间可以限制请求的意外积累,却无法修复过时的意图。保险库打开前创建的请求,仍然缺少证据证明代理当前的任务和用户当前的意图与原操作一致。过期时间适合用于批准卡,不适合用于隐藏的执行队列。

批准卡应该绑定哪些内容?

是。批准应绑定确切的方法、目标地址、凭据引用、请求摘要、代理进程和很短的有效时间。只写着“允许代理访问”的宽泛批准,会给攻击者和状态混乱的代理留下过大的修改空间。

锁定保险库导致的拒绝应该如何出现在审计日志中?

会话记录应显示进程在访问被锁定时尝试执行操作,以及网关在分发前拒绝了该操作。活动记录应标明尝试使用的通道和目标地址,但不能保存机密材料。拒绝是一项安全事件,不是可以忽略的噪声。

网关可以显示待处理请求,但不把它们排队吗?

可以,但前提是等待区中绝不能包含可分发的请求。系统只能保存类似“代理 X 需要访问服务 Y”的不可执行通知,并强制代理在解锁后提交新的完整请求。不要保留请求头、请求体、命令或授权状态。

无人值守时,自主代理运行会怎样?

即使代理无人值守运行,网关也应保留人工控制边界。如果没有人能够解锁保险库并批准操作,运行就应停止,报告被阻塞的任务,等待人稍后恢复或重新启动。

如果保险库经常锁定,怎样避免把凭据交给代理?

不要把凭据交给代理作为应急办法。可以等待人工控制的会话,或在独立系统中使用经过明确限制的非人类凭据,也可以重新设计任务,让它先生成可审查的计划,而不直接执行外部调用。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov