7 min read

Does locked vault request handling ever replay a call?

Test locked vault request handling so denied AI agent calls are discarded, never held in a queue and replayed after the vault opens.

Does locked vault request handling ever replay a call?

A locked vault must create a hard boundary in time. A request that reaches an action gateway before that boundary lifts should fail then and there. It should not sit in a queue, survive a reconnect, or become eligible because someone later authenticates to the vault.

That sounds obvious until you test an actual agent stack. Agents retry. MCP clients reconnect. HTTP libraries replay after a dropped response. Workers preserve jobs. A UI can show a denial while another component has already retained enough state to perform the call later. If you only watch the approval card or the agent transcript, you can miss the dangerous part.

The test described here answers one narrow question: did the gateway discard calls received while the vault was locked, or did unlocking cause any of those old calls to execute? It uses a controlled HTTP receiver, two unique request IDs, and evidence from both sides of the gateway. Do it before trusting an autonomous agent with any endpoint that can change money, infrastructure, source code, or customer data.

The vault lock must cut off an action, not postpone it

The expected rule is simple: when the vault is locked, the gateway denies every action that needs it. Unlocking changes the answer for a later call. It does not change the answer for a call that already arrived.

That distinction matters because a request has a life cycle. A process writes bytes to stdin. An MCP shim parses JSON-RPC. The gateway identifies a configured action, asks whether it may use a secret, injects credentials if allowed, opens an outbound connection, and returns a result. A bug can retain the request at several points in that path.

A secure design treats the locked decision as terminal for that invocation. The gateway may record the denial, but it must not keep an executable closure, a serialized request body, an outbound job, or a retry token that can later run under a newly opened vault.

People often blur two different behaviors:

  • A fresh retry is a new call sent by the agent after it observes an error or a state change.
  • A replay is execution of the original call, retained by the gateway or one of its helpers while access was denied.

A fresh retry can be legitimate, although it still needs normal authorization. A replay crosses a security boundary without a new decision. If you confuse them, you can falsely pass the test by unlocking, seeing one request reach the destination, and assuming it came from a deliberate retry.

The Model Context Protocol does not rescue you from this design problem. Its stdio transport uses newline-delimited JSON-RPC messages between a client-launched server process and the client. JSON-RPC requests with an id receive a correlated response, while notifications do not receive one. Those protocol rules give you useful correlation, but they do not define whether a gateway may preserve a denied action for later execution. Your gateway must make that decision explicitly.

Arrival time is earlier than the outbound HTTP call

A request arrives when the gateway has enough information to decide whether to perform it, not when the destination server sees traffic. If the vault is locked at that point, denial should happen before credential injection and before the gateway hands work to anything that can outlive the decision.

This is where tests get sloppy. Someone locks the vault, tells the agent to call an API, waits for an error, unlocks, and checks that no request appears immediately. That test misses delayed retries, blocked workers, connection pools, and client retry timers. It also misses the possibility that the call reached the destination before the UI showed an error.

Use three timestamps, recorded from independent places:

  1. T_lock: when the vault was confirmed locked.
  2. T_attempt: when the agent submitted the old request ID.
  3. T_unlock: when the vault was opened again.

Then keep watching the receiver after T_unlock. The wait must exceed every retry and timeout you configured in the client, the gateway, and any intermediary. If you do not know those values, do not choose a reassuringly short delay. Find them first, or use a receiver that stays available long enough to expose delayed delivery.

A useful acceptance statement is more precise than “the locked request failed”:

For request ID locked-..., the controlled receiver records zero executions before and after T_unlock; for request ID fresh-..., sent only after T_unlock, the receiver records exactly one execution.

That statement catches both sides of the failure. It detects an old call that runs later, and it proves the test did not fail merely because the receiver or action configuration was broken.

Do not use the same payload for both calls. If both say deploy=true, you cannot identify which one arrived. Put the request ID in the URL path, a harmless JSON field, and a header if your configured action lets you do so. Redundancy is useful here because it exposes accidental rewriting or caching.

Queues and retries are where replay defects hide

The most dangerous replay defects are not dramatic. They tend to come from ordinary reliability code written by someone who assumed that an authorization failure behaves like a transient network failure.

Consider a typical bad sequence. The agent sends an MCP tool call while the vault is locked. The shim accepts the message and creates an internal work item. The vault check returns a locked error, but the worker classifies it as retryable because the destination was never reached. The caller disconnects or the session exits. Later, the user unlocks the vault. The worker wakes, finds a usable credential, and sends the original HTTP request.

The UI may look correct throughout that sequence. The original agent got an error. The user saw the vault locked. The destination received a valid credential only after unlock. Yet the gateway carried an action across a boundary where it should have died.

These are the patterns worth hunting:

  • A generic retry wrapper catches every error except malformed input.
  • A persistent job queue stores intent before the vault decision.
  • A future or promise waits for unlock instead of returning a terminal error.
  • A reconnect path resends an in-memory request after the client process has gone away.
  • A background helper owns retry state independently from the vault gate.

The popular recommendation to “retry every failed network operation” is wrong at this boundary. It is popular because network transport failures are common and retries often improve delivery. A locked vault is not a transport failure. It is an explicit refusal to use authority. Classify it as terminal for that invocation.

This also applies to cancellation. A client disconnect does not necessarily mean that an HTTP or SSE request was cancelled. The MCP transport specification says a disconnection can occur at any time and should not itself be interpreted as cancellation; it calls for an explicit cancellation notification when the client wants to cancel. That behavior is sensible for long-running work, but it makes local gateway state more important: a denied call must not remain runnable just because transport state became ambiguous.

Build a receiver that makes every execution visible

A controlled receiver is better evidence than an agent chat transcript. It tells you whether an outbound call actually arrived, which ID it carried, and when it arrived. Keep it isolated from production and make its only side effect an append-only local log.

Run this small Python receiver on a machine and port your gateway can reach. It accepts POST requests, writes one JSON line for each arrival, and returns a harmless success response. It deliberately records only a short marker from the authorization header, not the credential itself.

# 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()

Start it with:

python3 receiver.py

The output file will have a shape like this:

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

Do not put a live secret in the request body. Configure the test action with a dedicated, low-privilege test credential in the vault, and let the gateway inject it through the normal HTTP channel. The receiver's header fingerprint only proves that some authorization value arrived; it does not print the value into a file that may outlive the test.

Before the locked test, send one ordinary request while the vault is open. Confirm that the receiver logs it and that the configured endpoint path is correct. Then delete receiver-events.jsonl or move it aside. Starting with an empty log prevents an earlier setup request from contaminating your result.

Run the test as two deliberately different calls

Keep secrets out of agents
Sallyport injects HTTP and SSH credentials itself, so agents never hold the secrets.

The test needs an old ID that is attempted while locked and a fresh ID that is created only after unlock. Use random-looking labels, but write them down before the run. Human-friendly labels make audit comparison easier.

For example:

old request ID:   locked-3d4a
fresh request ID: fresh-91ce

Prepare an agent instruction or an MCP client request that calls your configured HTTP action with this information:

{
  "path": "/replay-test/locked-3d4a",
  "headers": {
    "X-Replay-Test-Id": "locked-3d4a"
  },
  "body": {
    "kind": "locked-period-attempt",
    "request_id": "locked-3d4a"
  }
}

The precise tool name and argument shape depend on the action interface you configured. Do not fake a passing test by calling the receiver directly with a shell command. The request must travel through the same agent, MCP shim, gateway, vault, and HTTP action path that you intend to trust.

Now perform the sequence without improvising:

  1. Confirm that the receiver log is empty and the vault is locked.
  2. Start a new agent process, then submit the locked-3d4a call.
  3. Capture the agent-side error and the time. Do not resubmit the request.
  4. Leave the agent process alive for a short observation period, then terminate it. This catches both immediate and process-tied retry behavior.
  5. Open the vault and wait through the full observation period. Inspect the receiver log repeatedly. locked-3d4a must remain absent.
  6. Only after that wait, start a new agent process and submit fresh-91ce. The receiver should record that ID once.

Keep the old and fresh calls in separate agent processes. A session-level approval or an internal client cache can otherwise muddy the result. You are testing whether an old action can cross the lock boundary, not whether a single process remembers some authorization state.

If the locked call produces an approval prompt after unlock without the agent sending a new call, stop. That is evidence of retained intent. If the receiver receives locked-3d4a at any point after unlock, treat it as a failed security test even if the server returned 200 without changing data.

Inspect both journals, but do not let logs replace the receiver

A gateway journal helps you reconstruct what it believed happened. The receiver proves what happened outside the gateway. You need both because either source alone can create false confidence.

Sallyport keeps a Sessions journal for agent runs and an Activity journal for individual calls, both projected from one encrypted, hash-chained audit log. Its offline verifier is available through sp audit verify, and it does not need the vault key to validate the chain. Run that command after the test and preserve the result with your receiver log and agent transcript.

Use the records to answer concrete questions:

  • Did the old call create an activity entry, and does it show a denied outcome?
  • Which agent process and code-signing authority did the session record?
  • Is there any later activity with the old request ID, endpoint path, or matching time window?
  • Did a new session begin for the fresh post-unlock call?
  • Does audit verification succeed for the records you collected?

Do not assume that a log entry saying “denied” proves the request never left the machine. A log records the gateway's account of its own decision. The controlled receiver supplies the independent check. Conversely, a missing entry may mean your search terms were wrong, timestamps were skewed, or the test did not use the intended action. That is why the successful fresh request matters.

For a repeatable team test, save four artifacts under one test run ID: the agent transcript, the receiver JSONL file, the journal export or screenshots that identify the calls, and the result of sp audit verify. Avoid putting secret values in any of them.

Notifications, batches, and reconnects need separate cases

Put MCP actions behind control
Connect Claude Code or another MCP-capable agent through the bundled sp mcp stdio shim.

One passing request test does not cover every message form an agent client may send. Requests with IDs are the easiest to test because JSON-RPC requires the response to carry the same ID. Notifications have no ID and do not get a response, so they remove the normal proof that the gateway rejected them. JSON-RPC explicitly says a server must not reply to notifications.

For a notification-capable path, give the outbound payload a receiver-side marker such as notification-77b2. Lock the vault, cause the notification to be emitted once, unlock, and verify that the marker never arrives. Do not infer safety from silence in the client UI because silence is the expected protocol behavior.

Batch input deserves its own test if your client or shim accepts it. Put two harmless calls in the batch: one while locked and one sent only after unlocking in a separate batch. Do not put old and new calls into the same batch, because a gateway that processes part of a batch before state changes can produce a result you cannot interpret.

Reconnect tests should vary one thing at a time. Try these cases on separate runs:

  • Keep the agent process alive through unlock.
  • Kill the agent process before unlock.
  • Restart only the MCP client or shim before unlock.
  • Disconnect the network path after the locked error, then reconnect after unlock.

The expectation stays the same. The old receiver ID must never appear. If an old ID appears only after a reconnect, you found a replay path tied to transport recovery rather than the vault UI.

Per-session approval cannot repair a retained action

Keep HTTP credentials in vault
Route agent HTTP calls through the app instead of exposing bearer, basic, or custom-header credentials.

Per-session authorization and per-call approval decide whether a current action may proceed. They cannot make it safe to retain an action that was rejected while the vault was locked.

This ordering matters. The vault gate is absolute: while it is locked, every action is denied. Only after the vault opens can the gateway consider the process behind a session authorization decision or ask for per-call approval on a configured credential. Reversing that mental model leads teams to ask whether an earlier approval card should authorize a delayed action. It should not, because the locked call should no longer exist as runnable work.

Test the boundaries independently. First prove that a locked-period call never executes after unlock. Then, with the vault open, test that a new agent process produces the expected session authorization behavior. Finally, if a credential has the per-call approval flag, test that each fresh use asks again. Combining all three in one long run makes failures hard to assign.

There is also a subtle process identity issue. A session approval belongs to a particular agent process run, not to an idea of “the same assistant.” When you restart a process, treat it as new until the gateway identifies and authorizes it under its own rules. Do not let a test script hide this by reusing a process with an old connection.

Turn the result into a release gate

Run this test whenever you change gateway dispatch, vault lifecycle code, the MCP shim, retry behavior, HTTP client configuration, or the helper that performs SSH actions. A replay defect often enters during a reliability change because the code looks harmless in review: a queue, a reconnect handler, or one broad catch clause.

A release should fail if any of these statements is false:

  • The controlled receiver has zero entries for every ID submitted while locked, including after the vault opens.
  • A fresh post-unlock ID reaches the receiver once through the same configured action.
  • A restarted client or agent cannot cause the old ID to appear.
  • The journals identify the expected denied and allowed events without unexplained duplicates.
  • The audit chain verifies for the retained evidence.

Write the negative case into an automated integration test where possible. The test harness should lock the vault, issue the old call, open the vault, wait for a bounded retry window, and assert that the receiver has no old ID. Then it should issue the fresh call and assert one arrival. Keep the receiver local and disposable so the test has no authority beyond its own log file.

The bad outcome is easy to state: a person opens the vault to authorize the next action, and an earlier action runs instead. Do not accept a gateway that merely displays the right locked error. Make it prove, with an outside observer, that it forgot the old request.

FAQ

How do I prove a locked request was discarded instead of delayed?

A denial proves only that the gateway refused a call at one moment. A discard test goes further: unlock the vault, wait longer than every relevant retry window, and prove that the old request ID never reached a controlled destination. Then send a new request with a different ID to prove the test path still works.

Can an agent simply retry after I unlock the vault?

No. The gateway cannot stop an agent from making a fresh request after it learns the vault is open. Your test must distinguish a fresh post-unlock request from the old pre-unlock request with different request IDs and a receiver that records them separately.

Is a timeout enough evidence that no action happened?

A timeout is ambiguous because the client may retry it, the gateway may still be processing it, or the destination may have received it before the timeout surfaced. Treat a timeout as a test failure until the receiver log and the gateway's records establish what happened.

How should I test JSON-RPC notifications while the vault is locked?

Notifications deserve more suspicion because JSON-RPC gives the caller no response to correlate with the outcome. For a security-sensitive action, use a request with an ID in the test, or add an independent receiver-side marker that makes execution visible.

Does restarting the agent make an old request safe?

A new agent process must satisfy session authorization again if that control is enabled, but that fact alone does not prove locked-period calls were discarded. Test process restart and vault unlock as separate state changes, then inspect whether either one releases an old intent.

Do idempotency keys solve replay after unlock?

Only if the destination endpoint makes duplicate delivery harmless and records the idempotency key. An idempotency key prevents repeated effects at the destination; it does not authorize a gateway to retain an action that arrived while the vault was locked.

Can I run this test against a production API?

Use a receiver you control, a distinct test path, and data that has no operational value. Do not point this test at production billing, deployment, DNS, or source-control endpoints just because they already exist.

What is the fastest manual replay test?

First inspect the controlled receiver for the old ID, then send a fresh ID after unlock and confirm that it arrives once. If the old ID appears after unlock, treat the result as a security defect even if the destination did not change anything.

What audit evidence should I keep for this test?

You want a record that captures a denial or locked state, the agent session identity, and the request correlation value. A tamper-evident audit trail helps establish that those records were not quietly edited later, but it does not replace the receiver-side evidence.

What behavior should a vault gate guarantee?

A clean interface means the vault gate rejects the call before it enters any durable work queue or retry mechanism. Opening the vault changes permission for calls that arrive afterward; it must not turn earlier rejected calls into eligible work.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov