8 min read

Approval request binding prevents post-click changes

Approval request binding stops an AI client from changing an HTTP request or SSH command after a person has approved the action.

Approval request binding prevents post-click changes

A human approval is meaningful only if it authorizes an action that cannot change afterward. Showing an agent's proposed HTTP request or SSH command, then letting that same agent submit the final details later, leaves a time-of-check-to-time-of-use gap wide enough to drive a destructive request through.

I have seen this mistake arrive wearing reassuring clothes: a handsome approval sheet, a process identity badge, a green confirmation state. Then the implementation sends a client-held approval token back over the protocol and accepts a fresh URL or command beside it. The person approved one thing. The executor did another. That is a failed authorization design, even when nobody intended to bypass it.

A preview must describe an immutable authorization object

The approval screen must render a stored action object, and the executor must dispatch that same object after approval. The screen is a view of authoritative state, not a request for the client to repeat its proposal more carefully.

Create the object before any approval appears. Give it a random action identifier, associate it with the requesting process and session, resolve the channel-specific inputs, and retain it in memory or encrypted local storage under executor control. The client can receive a pending status and an identifier. It should not receive a capability that lets it attach new execution parameters later.

A useful mental model is a restaurant ticket, not a permission slip. The kitchen does not ask the customer to describe dinner again after the server marks the ticket approved. It prepares the ticket it holds. If the order changes, the kitchen receives a new ticket and the customer sees the change.

The immutable object should include more than the fields visible in a compact preview. It also needs the process identity, session identifier, creation time, expiry time, channel, credential reference, normalized action details, and a binding digest. The digest makes accidental mutation and deliberate substitution detectable, but the executor's ownership of the object does the actual security work.

An approval response should therefore mean only this: "approve action 7c91... with binding digest sha256:...". It must not mean "this process may perform an HTTP call" or "the caller may execute command X later." Those broader statements have their place in session authorization, but they cannot stand in for consent to a particular side effect.

This distinction matters most with autonomous agents because the client is not a trusted clerk faithfully transcribing a human decision. It is an active program that may retry, compose follow-up calls, alter its plan after a tool result, or simply contain a bug. Treat every field it can still influence after the preview as suspect.

An HTTP preview has to name every effective input

An HTTP approval binds the method, complete destination, effective headers, body, and dispatch behavior that can alter the request's meaning. A preview that says "POST to billing service" gives a person far too little information to approve a state-changing call.

RFC 9110 separates the request method, target, fields, and content because servers can assign different meaning to each of them. Authorization code should keep that separation. Do not build one pleasant sentence for the approval sheet and then let a lower layer fill in the awkward parts.

For an outbound request, capture these values after resolving templates and defaults:

  • Scheme, hostname, port, path, and query string.
  • HTTP method and whether the request may follow redirects.
  • Every effective header name and value, except secret material that the vault injects.
  • The final body bytes, their content type, length, and digest.
  • The credential reference and the injection mode, such as bearer or custom header.

The credential value does not belong in the preview or the agent's context. The person still needs enough information to judge its use. "Production payments credential sent only to api.example.test as a bearer token" tells a different story from "credential attached." The executor can render that description from a vault record without exposing the token.

Do not approve an unexpanded template. Suppose the agent proposes POST /users/{id}/role with a body that contains {role}. If another component expands those variables after approval, that component can change the effective target or privilege. Resolve the variables first, then create the action object. When an input is intentionally unavailable until later, ask for approval at the point it becomes concrete.

Headers deserve more care than most teams give them. A duplicate header, a changed Content-Type, an added override header, or a different query encoding can change server behavior while leaving a friendly summary looking identical. Preserve repeated fields in order when the protocol or target server gives order meaning. Reject ambiguous inputs rather than silently joining them with commas.

A sensible internal data shape might look like this. This is an implementation pattern, not an endpoint contract:

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

After the person approves this object, the transport layer reads http and credential_ref from its own stored record. It does not accept a second URL, header map, or body from the client. That last rule prevents the bug; the digest only proves which record the approval referred to.

SSH commands need their resolved execution context

An SSH command preview must bind the remote execution context, because a command string alone does not tell the person what will run. The same text can have different effects under a different user, host, shell, working directory, environment, or stdin stream.

RFC 4254 defines an SSH connection protocol request for command execution, but it does not create a human-readable approval boundary. SSH carries a command string to the server. Remote shell interpretation, account configuration, forced commands, and command wrappers can add meaning that a basic preview omits.

Store and present the hostname, port, remote user, host-authentication expectation, identity reference, exact command bytes, execution mode, terminal allocation, working directory, supplied environment, and stdin digest and length. For a file operation conducted through an SSH helper, bind the remote path, operation type, file digest, and any overwrite behavior as well.

The execution mode is not cosmetic. These are not interchangeable:

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

The first asks for direct execution if the helper supports it. The second explicitly asks a shell to parse the string. Shell parsing introduces expansion, command substitution, shell startup behavior, and quoting rules. If your product accepts both modes, label them plainly and never translate one into the other after approval.

Do not create a preview from a display-formatted command and execute a separately assembled array. A display string can hide an empty argument, a newline, a nonprinting character, or a difference between --file=/tmp/a b and two arguments. Render a safe escaped representation from the exact byte sequence or argument vector that the helper will use. If the command contains bytes that cannot be shown safely, refuse it or show an explicit encoded representation that the user can inspect.

Remote destination changes need the same discipline as HTTP redirects. A local SSH configuration alias, a proxy jump, or a host configuration lookup can turn a short host name into a different destination. Resolve the final connection plan before approval and bind it. For systems where DNS rebinding is part of the threat model, add a separate host-address policy with care; pinning an address can break ordinary failover, while ignoring a hostile resolver can send a valid hostname to the wrong machine. Do not pretend a command preview solves that separate problem.

The vulnerable interval begins after the person clicks

The failure happens when the UI checks proposed details, records a yes, and a later execution path reads mutable details from the client. It often hides behind ordinary asynchronous code.

Consider an agent process that asks a gateway to transfer funds:

  1. The client sends POST https://api.example.test/transfers with a body for 50 units.
  2. The gateway creates a preview and waits for a person to approve it.
  3. The person approves after reading the destination and amount.
  4. The client sends execute with the approval identifier plus a new body for 5,000 units.
  5. The gateway checks only that the approval identifier is valid, injects the credential, and sends the new body.

Nothing in this sequence requires a compromised UI or a stolen credential. The client used an API the gateway offered. A test that approves the original request and asserts only that the identifier works will pass. A test that compares the final outbound bytes to the bytes represented by the approved object will fail, which is exactly what you want.

The same fault appears when a gateway stores a mutable request object by reference. A preview worker reads it, an agent-side retry handler edits it, and the dispatch worker later sends the edited version. Copying the object is not a full answer if nested maps, body buffers, or callbacks remain shared. Build an immutable value with no client-owned references, or serialize it into an authoritative representation and reconstruct only from that representation.

Expiry does not repair mutable state. An approval that lasts thirty seconds can still authorize a changed request submitted in the first second. Rate limits do not repair it either. They reduce how often a client can exploit a mistake, not whether the person approved the effect.

Keep the state machine small enough to audit:

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

Only the transition from approved to dispatched may perform the external action. That transition must load the frozen record by action identifier, recheck caller and expiry, and hand the record directly to the HTTP or SSH executor. Any request to change details goes back to proposed and creates a new identifier.

Bind the bytes when semantics leave room to drift

See each outbound action
The Activity journal preserves each HTTP or SSH action without exposing vault secrets to the agent.

A binding needs a stable representation, or two components can believe they approved the same request while constructing different wire data. The hard part is deciding which representation deserves authority.

For simple JSON requests, normalize a structured form under documented rules, serialize it once, and retain the final body bytes and digest. The preview can show readable JSON while the dispatch path sends the retained bytes. Never parse, pretty-print, and reserialize after approval unless that transformed output was the object approved.

For forms, multipart uploads, repeated headers, or arbitrary binary input, bytes are usually the safer boundary. Bind the exact bytes, content type, and transfer framing decisions that affect server interpretation. A digest lets the UI identify a large body without dumping a private document into an approval sheet, but the executor must retain or regenerate those exact bytes from trusted state.

URL canonicalization is another trap. Normalizing a hostname's case is generally harmless. Decoding and re-encoding a path, sorting query pairs, dropping an empty value, or treating + and %20 as identical can change how an application routes or validates a request. Pick a narrowly defined canonical form, document it, and reject input that has more than one plausible interpretation. "We normalize URLs" is not a security property.

For SSH, an argument vector is better than a shell string when the protocol helper can execute it without invoking a shell. Bind each argument as a distinct byte sequence. When the remote side necessarily receives one shell command string, bind that string exactly and present its escaping truthfully. Do not claim that a parser on the local machine can prove what an arbitrary remote shell startup file will do.

The approval digest should cover a versioned, domain-separated encoding. Prefix the encoded data with a fixed label such as action-v1/http or action-v1/ssh, include all immutable fields with unambiguous lengths or a deterministic serializer, and then hash it. Versioning prevents an old interpretation from silently becoming a new one after an upgrade. Domain separation prevents an HTTP object from ever matching an SSH object merely because their serialized bytes happen to coincide.

Caller identity and action binding solve different problems

Per-session authorization tells you which running process may request actions. Action binding tells you which exact action that process may execute. You need both, and neither replaces the other.

Bind the action to the process identity observed by the gateway, not to a name the client sends. On macOS, that can include the process code-signing authority, process identifier, and process start instance. The start instance matters because an operating system can reuse a process identifier. If the process exits, discard its pending and approved actions along with the session authority.

Do not let a child process inherit a broad approval merely because it knows its parent's identifier. Observe each connecting process at the gateway boundary. If a tool architecture makes that impossible, state the limitation plainly and narrow the approval period rather than inventing confidence you do not have.

Human approval also needs a short, bounded lifetime. The expiry belongs in the frozen object and the approval decision, not only in a timer inside the UI. When the expiry passes, the executor rejects dispatch even if the client has held an old response the entire time. A revoked session should likewise prevent dispatch of any of its approved but unused actions.

Sallyport's per-session authorization can establish whether a newly connected agent process may use a run at all, while an action gateway still needs this separate immutable boundary for each previewed side effect. A session decision is intentionally broad; an HTTP request or SSH command approval must remain exact.

Do not make process identity a reason to show less detail. Trusted signed code can have defects, unexpected prompt injection inputs, or a plugin that took a different branch than its author expected. The value of a human gate is that it can see the concrete effect before it leaves the machine.

Retries and redirects must stay inside the approved boundary

End a risky agent run
Revoke an agent run instantly from the Sessions journal when its authority should end.

A transport retry can reuse approval only when it resends the same frozen action under recorded conditions. Many retry implementations quietly violate that rule by rebuilding requests from mutable client state.

Define the allowed retry behavior when you freeze the action. For example, permit one resend after a connection failure before any HTTP response arrives, with the same URL, headers, body bytes, and credential reference. Record each attempt against the same action identifier. Do not retry a non-idempotent request after an uncertain response unless the protocol and target API give you a dependable idempotency mechanism.

An idempotency token is useful only if it is itself bound. If an executor generates one, generate it while freezing the action and retain it in the approved header set. If the client supplies one, treat it as part of the approved request. Replacing it on retry can make one approval produce a second side effect.

Authentication recovery deserves equal suspicion. If a vault refreshes a credential internally but sends the identical approved request to the identical destination, the action may remain within the boundary. If recovery changes tenant, destination, scope, or headers visible to the application, it is a different request and needs a new approval. Avoid a background mechanism that turns an authorization failure into an unreviewed alternate call.

Default HTTP redirect behavior should be deny for approved actions. A redirect that preserves every relevant field can still cross to a different authority. If the product supports following redirects, inspect each hop before dispatch and require a fresh action object when the host, port, method, or body changes. Treat a relative redirect on the same authority according to an explicit rule rather than whatever an underlying client library happens to do.

SSH reconnects follow a similar rule. Reconnect only to the bound host plan with the bound identity and command. A newly discovered host fingerprint, a changed proxy route, or a changed host resolution result can require a fresh human decision depending on your threat model. The retry code must be boring enough that an auditor can see it could not widen the original approval.

Audit the approval and the execution as separate events

An audit record must preserve both the action the person reviewed and the attempts the executor made. A log that records only an eventual request cannot prove whether the preview matched it, and a log that records only approval cannot prove whether dispatch occurred.

Record the frozen action's identifier, binding digest, caller identity, preview-safe fields, credential reference identifier, approval time, expiry, and outcome. Then record every dispatch attempt with the same identifier, transport result, retry reason if any, and a digest of the actual sent body or command. Keep secret values out of both the user-facing journal and the agent response.

This is where a hash-chained log earns its keep. A chain can reveal removal or rearrangement of prior entries when verification covers the stored sequence. It cannot make a vague event useful. If the record says only "approved SSH action," the chain faithfully preserves a vague event forever.

For a gateway that keeps a write-blind encrypted, hash-chained log, an offline verifier should check the ciphertext chain independently of vault access. Sallyport exposes that check through sp audit verify, which is useful because an investigator can verify continuity without first opening the credential vault.

Audit output should make the binding visible. A reviewer should be able to see that approval 7c91e2d4 covered digest 4f06..., that dispatch used the same digest, and that the executor rejected any mismatched proposal. Avoid logs that print a human summary at approval time and a separate raw request at execution time with no durable join between them.

Test the client as if it wants to change its mind

Inject API credentials safely
Use bearer, basic, or custom-header injection without placing API keys in agent context.

A normal integration test proves that an approved action succeeds. A security test must prove that an approved action cannot turn into a different one, even when the client behaves badly after the person clicks.

Build a harness that pauses immediately after approval. While dispatch waits, mutate each client-controlled input one at a time: method, host, port, path, query, header values, repeated headers, body, credential selector, redirect setting, SSH user, command, environment, stdin, and file digest. The executor should either dispatch the original stored action or reject the attempt. It should never dispatch the mutation.

Test race conditions as deliberately as field changes. Send two execute messages for one approved identifier. Cancel approval and send execute at the same instant. Let the requesting process exit, then reconnect a new process that tries the old identifier. Restart the UI while an approval waits. Fill the pending-action store until expiry cleanup runs. These are ordinary lifecycle events, and authorization code often fails in the seams between them.

Use a fake HTTP server that reports the exact method, target, duplicate headers, and body digest it received. Use a controlled SSH endpoint that records the remote user, command bytes, terminal request, environment, and stdin digest. Assert against those observations, not only against the gateway's intent object. The whole point is to catch a divergence between intent and transport.

Finally, make failed tests readable. A useful failure says that approval digest 4f06... covered PATCH /v1/users/42, while the attempted dispatch carried a different digest and was rejected. A vague "authorization failed" message sends engineers back to print statements and encourages them to weaken checks until the test goes green.

Put the immutability boundary beside the executor

The component that holds credentials and opens the HTTP or SSH connection must own the frozen approved action. Any earlier boundary leaves another layer free to reinterpret, rebuild, or replace it.

That design also keeps the human interaction honest. The person sees a preview generated from the object that will be sent; the agent receives results, not credentials; and the audit trail has one identifier that joins intent, approval, and execution. These are separate benefits, but they all depend on the same refusal to accept post-click changes.

If your current flow returns an approval token to the agent and accepts fresh action fields afterward, fix that before adding better copy, more policy controls, or another approval dialog. Freeze the request first. Then make the executor prove it used the frozen record.

FAQ

What is approval request binding?

A preview is trustworthy only when the executor stores the exact approved action and refuses any later client-supplied replacement. If approval merely returns a reusable “yes” to the client, the preview is decorative.

Does session approval prevent request changes after approval?

No. A signed-in process can still send a different request after the person reads the preview. Session approval answers who may ask; request binding answers what that process may execute.

Which HTTP fields must an approval bind?

Include the method, full destination, headers after credential injection, body bytes or digest, redirect behavior, and the credential reference. Do not display secret values, but do identify the credential and its intended destination.

What should an SSH approval preview include?

At minimum, bind the host, port, remote user, authentication identity, execution mode, exact command bytes, environment, working directory, terminal allocation, and stdin or transfer content. A command label such as “deploy” is not enough.

Are HTTP redirects safe after an action is approved?

Treat a redirect as a new outbound request when it changes the host, method, or body. Following it silently lets a server choose an action the person never reviewed.

Can an approved request be retried safely?

Retries can reuse approval only when they resend the same immutable action under a narrow, recorded retry rule. A retry that changes a body, destination, credential, or idempotency behavior needs a new decision.

Should the client receive an approval token?

Usually no. A client-held approval token can be copied, replayed, or paired with altered parameters unless the executor independently compares it with stored immutable state. Keep the authoritative action inside the process that performs the network or SSH call.

Should I hash raw HTTP bytes or canonical fields?

Use a canonical field representation when semantic equivalence is well defined, then retain a digest of the final wire body where it is not. Be especially strict with repeated headers, query encoding, multipart bodies, and shell command strings.

What should audit logs record for approved actions?

Approval records show what the person was asked to authorize. Execution records show what the executor attempted and what happened, including retries and failures; retaining both makes investigations much less speculative.

How do I test for approval time-of-check bugs?

Build adversarial tests that approve one action and then mutate every client-controlled field before dispatch. Test redirects, duplicate headers, shell parsing, process replacement, replay, expiry, and an interrupted approval flow.

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