锁定保险库的请求处理会重放调用吗?
测试锁定保险库的请求处理,确认被拒绝的 AI 代理调用会被丢弃,不会留在队列中,也不会在保险库打开后被重放。

锁定的保险库必须在时间上形成一道明确的边界。请求如果在边界解除前到达操作网关,就应该当场失败。它不应留在队列中,不应在重新连接后继续存在,也不应因为有人后来向保险库完成身份验证,就获得执行资格。
听起来很简单,但真正测试代理技术栈时,情况往往不同。代理会重试,MCP 客户端会重新连接,HTTP 库会在响应中断后重放请求,工作进程会保留任务。界面可能显示请求被拒绝,但另一个组件已经保留了足够的状态,准备稍后执行调用。如果你只查看批准卡片或代理对话记录,就可能错过危险的部分。
本文只回答一个具体问题:网关是否丢弃了保险库锁定期间收到的调用,还是解锁后让其中某个旧调用执行了?测试使用受控 HTTP 接收器、两个唯一的请求 ID,以及来自网关两侧的证据。在把任何能够改变资金、基础设施、源代码或客户数据的端点交给自主代理前,请先完成这项测试。
保险库锁定必须切断操作,而不是推迟操作
预期规则很简单:保险库锁定时,网关拒绝所有需要它的操作。解锁会改变之后调用的结果,但不会改变已经到达的调用的结果。
这个区别很重要,因为请求有自己的生命周期。进程把字节写入 stdin,MCP shim 解析 JSON-RPC,网关识别已配置的操作,询问是否可以使用密钥,在获准后注入凭据,打开出站连接,然后返回结果。请求可能在这条路径上的多个位置被错误地保留下来。
安全设计会把锁定状态下的决定视为本次调用的终点。网关可以记录拒绝,但不能保留可执行的闭包、序列化的请求正文、出站任务,或能在保险库重新打开后运行的重试令牌。
人们经常把两种行为混为一谈:
- 全新重试是代理观察到错误或状态变化后发出的新调用。
- 重放是网关或其某个辅助组件在访问被拒后保留原始调用,随后执行它。
全新重试可能是合理的,但仍然需要经过正常授权。重放则是在没有新决定的情况下跨过安全边界。如果把两者混淆,就可能在解锁后看到一个请求到达目标,然后错误地认为它来自有意发起的重试,从而让测试虚假通过。
Model Context Protocol 无法替你解决这个设计问题。它的 stdio 传输在客户端启动的服务器进程与客户端之间传递以换行分隔的 JSON-RPC 消息。带有 id 的 JSON-RPC 请求会收到相关联的响应,而通知不会收到响应。这些协议规则能帮助你进行关联,但并没有规定网关是否可以保留被拒绝的操作并在之后执行。这个决定必须由你的网关明确做出。
到达时间早于出站 HTTP 调用
请求到达的时间点,是网关获得足够信息来决定是否执行请求的时间点,而不是目标服务器看到流量的时间点。如果此时保险库处于锁定状态,就应在注入凭据之前拒绝请求,也应在网关把工作交给任何可能让该决定持续存在的组件之前拒绝请求。
测试经常在这里变得不严谨。有人锁定保险库,让代理调用 API,等到错误出现,再解锁并检查是否没有请求立刻出现。这样的测试会漏掉延迟重试、阻塞的工作进程、连接池和客户端重试计时器,也会漏掉另一种可能:调用在界面显示错误前就已经到达目标。
请从相互独立的位置记录三个时间戳:
T_lock:确认保险库已锁定的时间。T_attempt:代理提交旧请求 ID 的时间。T_unlock:保险库再次打开的时间。
然后在 T_unlock 之后继续观察接收器。等待时间必须超过客户端、网关和任何中间组件中配置的所有重试与超时。如果你不知道这些值,不要选择一个令人安心的短暂等待时间。先找出它们,或者使用能够保持足够长时间可用的接收器,以暴露延迟投递。
有一条比“锁定请求失败了”更精确的验收标准:
对于请求 ID
locked-...,受控接收器在T_unlock之前和之后都记录零次执行;对于仅在T_unlock之后发送的请求 IDfresh-...,接收器准确记录一次执行。
这条标准能同时捕捉两类问题。它能发现旧调用稍后执行,也能证明测试不是因为接收器或操作配置损坏而失败。
不要为两个调用使用相同的请求内容。如果两者都写着 deploy=true,你就无法判断到底是哪一个到达了。请将请求 ID 放入 URL 路径、无害的 JSON 字段,以及请求头中,前提是你配置的操作允许这样做。在这里,冗余很有用,因为它可以暴露意外的重写或缓存。
重放缺陷藏在队列和重试中
最危险的重放缺陷通常并不明显。它们往往来自普通的可靠性代码,编写者误以为授权失败和暂时性的网络失败是同一种情况。
设想一个典型的错误流程。代理在保险库锁定时发送 MCP 工具调用。shim 接收消息并创建内部工作项。保险库检查返回锁定错误,但工作进程把它归类为可重试错误,因为请求从未到达目标。调用方断开连接或会话退出。之后用户解锁保险库,工作进程被唤醒,发现凭据可用,于是发送原始 HTTP 请求。
在整个流程中,界面可能看起来都没问题。原始代理收到了错误,用户看到了保险库锁定,目标只在解锁后收到有效凭据。然而,网关还是让一个本应在边界处终止的操作跨过了边界。
值得重点排查的模式包括:
- 通用重试包装器捕获除输入格式错误以外的所有错误。
- 持久化任务队列在保险库决定之前就保存了意图。
- future 或 promise 等待解锁,而不是返回终止性错误。
- 重新连接路径在客户端进程退出后重新发送内存中的请求。
- 后台辅助组件独立于保险库门控,拥有自己的重试状态。
“每个失败的网络操作都重试”的常见建议不适用于这个边界。它之所以流行,是因为网络传输失败很常见,重试也经常能提高投递成功率。但锁定的保险库不是传输失败,而是明确拒绝使用权限。对于本次调用,应将它归类为终止状态。
这同样适用于取消。客户端断开连接并不一定意味着 HTTP 或 SSE 请求已被取消。MCP 传输规范指出,断开连接可能随时发生,不能仅凭断开本身将其解释为取消;客户端希望取消时,应发送明确的取消通知。对于长时间运行的工作,这种行为很合理,但也让网关本地状态更加重要:被拒绝的调用不能仅仅因为传输状态变得不明确,就继续保持可执行状态。
构建一个让每次执行都可见的接收器
受控接收器比代理聊天记录更有说服力。它能告诉你出站调用是否真的到达、携带了哪个 ID,以及到达时间。请将它与生产环境隔离,并让它唯一的副作用是向本地追加日志。
在网关可以访问的机器和端口上运行下面这个 Python 接收器。它接受 POST 请求,为每次到达写入一行 JSON,并返回无害的成功响应。它只记录授权请求头的短标记,不记录凭据本身。
# receiver.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime, timezone
import hashlib
import json
LOG = "receiver-events.jsonl"
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length).decode("utf-8", errors="replace")
auth = self.headers.get("Authorization", "")
auth_marker = hashlib.sha256(auth.encode()).hexdigest()[:12] if auth else None
event = {
"received_at": datetime.now(timezone.utc).isoformat(),
"method": self.command,
"path": self.path,
"request_id": self.headers.get("X-Replay-Test-Id"),
"auth_marker": auth_marker,
"body": body,
}
with open(LOG, "a", encoding="utf-8") as log:
log.write(json.dumps(event) + "\n")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"received":true}')
def log_message(self, format, *args):
return
HTTPServer(("127.0.0.1", 8787), Receiver).serve_forever()
启动命令:
python3 receiver.py
输出文件大致如下:
{"received_at":"2026-07-22T16:42:12.103841+00:00","method":"POST","path":"/replay-test/fresh-8f1c","request_id":"fresh-8f1c","auth_marker":"a4d7e02c1b9f","body":"{\"kind\":\"fresh\"}"}
不要把真实密钥放进请求正文。请在保险库中为测试操作配置一个专用的低权限测试凭据,让网关通过正常的 HTTP 通道注入它。接收器中的请求头指纹只能证明某个授权值到达过,不会把该值写入可能长期留存的文件。
在进行锁定测试前,先在保险库打开时发送一个普通请求。确认接收器记录了该请求,并确认配置的端点路径正确。然后删除 receiver-events.jsonl 或将它移到别处。用空日志开始测试,可以避免之前的设置请求污染结果。
将测试作为两个有意区分的调用运行
测试需要一个在锁定期间尝试使用的旧 ID,以及一个只在解锁后创建的新 ID。使用看起来随机的标签,并在运行前记下来。便于人阅读的标签能让审计比对更简单。
例如:
old request ID: locked-3d4a
fresh request ID: fresh-91ce
准备一条代理指令或一个 MCP 客户端请求,用以下信息调用已配置的 HTTP 操作:
{
"path": "/replay-test/locked-3d4a",
"headers": {
"X-Replay-Test-Id": "locked-3d4a"
},
"body": {
"kind": "locked-period-attempt",
"request_id": "locked-3d4a"
}
}
具体的工具名称和参数格式取决于你配置的操作接口。不要用 shell 命令直接调用接收器来伪造通过的测试。请求必须经过你准备信任的同一条代理、MCP shim、网关、保险库和 HTTP 操作路径。
现在不要临时改变步骤,按以下顺序执行:
- 确认接收器日志为空,并确认保险库处于锁定状态。
- 启动新的代理进程,然后提交
locked-3d4a调用。 - 记录代理一侧的错误和时间。不要重新提交请求。
- 让代理进程保持运行一小段观察时间,然后终止它。这样可以捕捉即时重试和绑定进程的重试行为。
- 打开保险库,等待完整的观察时间。反复检查接收器日志。
locked-3d4a必须始终不存在。 - 只有等待结束后,才启动新的代理进程并提交
fresh-91ce。接收器应记录这个 ID 一次。
让旧调用和新调用分别运行在不同的代理进程中。否则,会话级批准或内部客户端缓存可能让结果变得模糊。你要测试的是旧操作能否跨过锁定边界,而不是单个进程是否记住了某种授权状态。
如果锁定调用在解锁后产生了批准提示,而代理并没有发送新的调用,请停止测试。这说明意图被保留了。如果接收器在解锁后的任何时间收到 locked-3d4a,都应将其视为安全测试失败,即使服务器返回了 200 且没有改变任何数据。
检查两份日志,但不要让日志取代接收器
网关日志能帮助你还原网关认为发生了什么。接收器则证明网关之外实际发生了什么。两者都需要,因为单独依赖任何一方都可能制造虚假的安全感。
Sallyport 为代理运行维护 Sessions 日志,为单个调用维护 Activity 日志,这两份日志都由同一份加密、哈希链式审计日志投影而来。它的离线验证器可通过 sp audit verify 使用,并且不需要保险库密钥即可验证链条。测试结束后运行该命令,并将结果与接收器日志和代理记录一起保存。
利用这些记录回答具体问题:
- 旧调用是否创建了 Activity 条目?它是否显示为被拒绝?
- 会话记录中包含哪个代理进程和代码签名方?
- 是否存在带有旧请求 ID、端点路径或匹配时间范围的后续 Activity?
- 解锁后的新调用是否启动了新的会话?
- 你收集的记录能否通过审计验证?
不要认为写着“已拒绝”的日志条目就能证明请求从未离开机器。日志记录的是网关对自身决定的描述,受控接收器才是独立检查。反过来,缺少日志条目也可能意味着搜索条件错误、时间戳不一致,或者测试没有使用预期的操作。因此,成功的新请求很重要。
如果要让团队重复运行测试,请在同一个测试运行 ID 下保存四份材料:代理记录、接收器 JSONL 文件、能够识别调用的日志导出或截图,以及 sp audit verify 的结果。不要在其中任何一份材料里放入密钥值。
通知、批量请求和重新连接需要单独测试
一次通过的请求测试无法覆盖代理客户端可能发送的所有消息形式。带 ID 的请求最容易测试,因为 JSON-RPC 要求响应携带相同的 ID。通知没有 ID,也不会收到响应,因此无法获得网关拒绝它们的常规证明。JSON-RPC 明确规定,服务器不得回复通知。
对于支持通知的路径,请给出站请求内容添加一个接收器一侧的标记,例如 notification-77b2。锁定保险库,让通知只发送一次,解锁后确认该标记从未到达。不要从客户端界面的安静状态推断安全,因为通知本来就不会产生协议响应。
如果客户端或 shim 接受批量输入,就应为批量输入单独测试。批量中放入两个无害调用:一个在锁定时发送,另一个在解锁后通过单独的批次发送。不要把旧调用和新调用放进同一批次,因为网关可能在状态变化前处理完批次的一部分,导致结果无法解释。
重新连接测试应当每次只改变一个条件。分别运行以下情况:
- 让代理进程在解锁期间保持运行。
- 在解锁前终止代理进程。
- 在解锁前只重启 MCP 客户端或 shim。
- 锁定错误出现后断开网络路径,在解锁后重新连接。
预期结果不变。旧的接收器 ID 绝不能出现。如果旧 ID 只在重新连接后出现,就说明你找到了与传输恢复有关的重放路径,而不是与保险库界面有关的路径。
会话级批准无法修复已保留的操作
会话授权和逐次调用批准决定当前操作是否可以继续。它们无法让保险库锁定时被拒绝、却被保留下来的操作变得安全。
顺序很重要。保险库门控是绝对规则:保险库锁定时,所有操作都被拒绝。只有保险库打开后,网关才能根据会话授权决定检查进程,或针对已配置的凭据请求逐次调用批准。反过来理解,就会有人问:此前的批准卡片是否应当授权延迟的操作。答案是否定的,因为锁定期间的调用不应再作为可执行工作存在。
请分别测试这些边界。先证明锁定期间的调用不会在解锁后执行。然后在保险库打开时,测试新的代理进程是否产生预期的会话授权行为。最后,如果凭据启用了逐次调用批准,再测试每次全新的使用是否都会再次请求批准。把三者混在一次很长的运行中,会让问题难以定位。
还要注意一个细微的进程身份问题。会话批准属于某一次特定的代理进程运行,而不是属于“同一个助手”这一抽象概念。重启进程后,在网关按照自身规则识别并授权它之前,应将它视为新的进程。不要让测试脚本复用旧连接的进程来掩盖这一点。
把结果变成发布门槛
每当你修改网关分发、保险库生命周期代码、MCP shim、重试行为、HTTP 客户端配置,或执行 SSH 操作的辅助组件时,都应运行这项测试。重放缺陷经常在可靠性改动中出现,因为代码在评审时看起来很普通:一个队列、一个重新连接处理器,或者一条宽泛的 catch 子句。
如果以下任何一项不成立,发布都应失败:
- 对于锁定期间提交的每个 ID,受控接收器都没有记录,包括保险库打开之后。
- 解锁后发送的新 ID 通过同一个已配置操作到达接收器一次。
- 重启客户端或代理不会让旧 ID 出现。
- 日志能识别预期的拒绝和允许事件,没有无法解释的重复记录。
- 留存的证据能够通过审计链验证。
尽可能把否定场景写入自动化集成测试。测试工具应锁定保险库,发出旧调用,打开保险库,等待有界的重试窗口,并断言接收器没有旧 ID。然后发出新调用,断言它到达一次。让接收器保持本地且可随时销毁,这样测试不会拥有超出自身日志文件的权限。
糟糕的结果很容易描述:有人打开保险库是为了授权下一次操作,结果更早的操作却执行了。不要满足于网关只是显示正确的锁定错误。请通过外部观察者让它证明,自己已经忘记了旧请求。
常见问题
如何证明锁定期间的请求被丢弃,而不是被延迟执行?
拒绝只能证明网关在某个时刻拒绝了调用。丢弃测试还需要继续验证:打开保险库,等待超过所有相关的重试窗口,并确认旧请求 ID 从未到达受控目标。然后使用不同的 ID 发送新请求,证明测试链路仍然正常。
打开保险库后,代理可以直接重试吗?
不能。网关无法阻止代理在得知保险库已打开后发起新的请求。测试必须通过不同的请求 ID,以及能分别记录这些 ID 的接收器,区分解锁后的新请求和解锁前的旧请求。
超时足以证明没有发生操作吗?
超时并不能说明问题,因为客户端可能会重试,网关可能仍在处理请求,或者目标服务器早已在超时出现前收到请求。在接收器日志和网关记录确认实际情况之前,应把超时视为测试失败。
保险库锁定时,应该如何测试 JSON-RPC 通知?
通知更需要谨慎,因为 JSON-RPC 不会向调用方返回可用于关联结果的响应。对于涉及安全的操作,测试时应使用带 ID 的请求,或者添加独立的接收器标记,让执行行为清晰可见。
重启代理能让旧请求变安全吗?
如果启用了会话授权,新的代理进程必须再次通过授权,但这本身不能证明锁定期间的调用已被丢弃。应将进程重启和保险库解锁作为两个独立的状态变化来测试,然后检查其中任何一个变化是否释放了旧意图。
幂等键能解决解锁后的重放问题吗?
只有当目标端点能让重复投递不会造成危害,并且会记录幂等键时,幂等键才有帮助。幂等键可以防止目标端点重复产生效果,但不能授权网关保留保险库锁定期间到达的操作。
可以针对生产 API 运行这个测试吗?
请使用你能控制的接收器、独立的测试路径,以及没有实际运营价值的数据。不要因为生产计费、部署、DNS 或源代码管理端点已经存在,就把测试指向它们。
最快的人工重放测试是什么?
先检查受控接收器中是否出现旧 ID,然后在解锁后发送一个新 ID,并确认它只到达一次。如果旧 ID 在解锁后出现,即使目标没有发生任何变化,也应将结果视为安全缺陷。
这个测试应该保留哪些审计证据?
需要保留能够记录拒绝或锁定状态、代理会话身份以及请求关联值的证据。防篡改审计记录有助于证明这些记录后来没有被悄悄修改,但它不能替代接收器一侧的证据。
保险库网关应保证什么行为?
清晰的接口应保证保险库网关在调用进入任何持久化工作队列或重试机制之前就拒绝它。打开保险库只会改变之后到达的调用的权限,不得让此前被拒绝的调用重新变成可执行任务。