Why are look-alike approval requests dangerous?
Look-alike approval requests can authorize different actions. Learn how to design and test approvals that expose the exact request a human is accepting.

A human approval has security value only when the person can tell what the click authorizes. If two requests look the same on screen while their bodies, headers, run identity, or commands differ, the approval is theater. The person did not review the action. They recognized a familiar label and moved on.
That failure is easy to create in agent tooling because the visible summary usually starts with the least interesting fields: a title, a hostname, a method, perhaps a short command. Those fields help a person orient themselves, but they do not define authority. A POST to the same endpoint can create a harmless draft or release a production change. An SSH command can print a version or remove a directory. A request sent under a different tenant header can cross a boundary even when every word in the URL stays put.
I have watched approval flows become useless through good intentions. Someone wants fewer interruptions, so they group similar calls. Someone wants a tidy card, so they collapse the request body. Someone wants a stable label for analytics, so they reuse it for every action in a family. By the time a reviewer sees the tenth familiar card, the system has taught them that the details do not matter. The attacker, or the buggy agent, needs only one hidden difference.
Identical labels can hide different authority
A title and destination are clues, not the object a person is authorizing. Treating them as identity creates a collision class: many distinct operations collapse into one recognizable prompt.
Consider a deployment service with one endpoint, POST /v1/releases. An agent first submits a draft release and later asks to publish it. If both cards say "Release request" and show api.example.test, the reviewer must open a detail drawer and read a body to distinguish them. In practice, repeated cards teach people that the drawer contains the same noise every time. The publish request arrives after that training.
The same issue appears in more ordinary work. A source-control API may use the same path to update a pull request title, alter a merge setting, or replace reviewers. A cloud API may use the same resource path for a dry run and an actual change through one field. A ticketing endpoint may write to a private incident or a public status page depending on a header. The destination tells you where the request arrived. It does not tell you what the remote service will do.
For commands, the false similarity often starts with a friendly display string. These pairs should never share an approval identity:
find build -type f -deleteandfind build -type f -printgit push origin HEADfrom a personal clone and from a release checkoutcurl -X POSTwith a JSON body that creates a draft and one that sends a messagessh deploy@hostusing a read-only account and using an account permitted to change service files
The distinction people blur is similarity versus equivalence. Similarity says two actions have enough in common to group visually. Equivalence says one approval legitimately covers the other action. The first is a presentation choice. The second grants authority. Mixing them is how a compact interface silently widens permission.
A prompt may group repeated actions for readability, but it must never let that grouping decide whether an earlier click applies. The authorization check needs a stricter object than the label on the card.
An approval must bind to the executed request
The approved object should be a canonical description of the action the executor will send, plus the identity context under which it will send it. If any authority-bearing field changes, the system must ask again or make the changed field unmistakable and require a fresh decision.
For HTTP, that description usually includes the method, scheme and host, normalized path, query values, body bytes or a defined canonical body form, selected headers, credential reference, and the agent run. Do not put secret values in the display or log merely to achieve this. A credential reference such as payments-production identifies the authority without exposing its token. The executor can bind the approval to the internal vault record that it will actually use.
Headers deserve more care than most interfaces give them. Some headers only affect transport decoration. Others select an organization, enable an administrative mode, set an idempotency scope, choose a regional account, or change how the receiver interprets the body. Maintain an explicit allowlist of fields that may be omitted because they cannot affect the remote action. Everything else should appear in the canonical identity until someone can prove it is harmless to exclude.
For a command, build the description from the execution plan rather than from a shell string. The plan includes the executable, argument vector, working directory, target host when applicable, user identity, credential reference, and environment entries that affect behavior. A shell string is a lossy display format. Quoting rules, expansion, inherited variables, and a changed working directory can all turn the same-looking line into another action.
This is where teams often argue for a permissive rule such as "same destination and same verb is close enough." It is popular because it reduces prompts quickly and makes demos feel smooth. It is wrong because HTTP verbs describe broad classes, not consequences. POST is not a permission category, and neither is ssh.
Use a request fingerprint to bind the decision, but do not show a fingerprint as the sole evidence. A digest is excellent for an equality check inside the approval engine. Humans need the fields that led to the result. Give them both: structured, readable differences on the card and an exact canonical identity behind the card.
Repetition is not proof that a call is safe
A retry can share every execution field with an earlier attempt and still need a different treatment from an unrelated duplicate. The system must classify why it repeats before it decides whether to suppress a prompt.
The safest repeated-call case is a transport retry where the executor knows the first request never reached the remote service, or the remote service supplies an idempotency mechanism tied to the exact operation. Even then, the approval engine should bind the retry to the original approved operation and record the retry relationship. It should not merely notice that two hashes match somewhere in recent history.
A duplicate after an uncertain network failure is different. The remote service may have accepted the first call before the connection broke. Replaying it could send a second email, create a second issue, or bill twice. The reviewer needs language that says the outcome of the earlier attempt is unknown. A familiar-looking card that says only "Retrying request" hides the decision they need to make.
A later identical action from another run is different again. It may be a new agent process, new code, a copied terminal session, or another person using the same machine. Per-run authorization exists because process identity and lifetime matter. Reusing an old approval across runs turns a bounded decision into a standing grant.
Use separate states in your model:
- The user approved an exact planned action.
- The executor attempted that action and knows whether it sent it.
- The remote side reported success, failure, or an unknown outcome.
- A later action claims a relationship to the first action.
Do not collapse those states into a green badge. An approval records intent. A transport result records delivery evidence. A remote response records an outcome. They answer different questions, and the audit record should preserve all three.
Bodies need semantic review and byte-level binding
A request body can carry the entire consequence of an API call, so hiding it behind a generic "payload attached" label is a design error. Show a readable form for review, then bind the approval to the exact representation the executor sends.
JSON makes this awkward because its appearance and meaning can differ. Object field order often does not change a receiver's interpretation, while an array order may change it completely. Whitespace normally does not matter, but a string containing whitespace does. A number written as 1 may be treated differently from 1.0 by a service that does loose or custom decoding. Do not invent a universal JSON normalizer and assume it preserves semantics.
A practical approach has two layers. First, preserve the outgoing body bytes and compute a cryptographic digest over them for the authorization binding. Second, parse known content types into a display tree that makes the fields readable. If the parser cannot safely interpret the body, show an escaped excerpt, its length, and a digest, then require the person to expand it before approval when the call has consequences.
Test with pairs built to fool a casual reviewer. The two fixture files below share a title, method, and destination. They must produce different fingerprints and a visible difference in the approval view.
{
"title": "Release request",
"method": "POST",
"url": "https://api.example.test/v1/releases",
"headers": {"Content-Type": "application/json"},
"body": {"version": "2.4.1", "state": "draft"}
}
{
"title": "Release request",
"method": "POST",
"url": "https://api.example.test/v1/releases",
"headers": {"Content-Type": "application/json"},
"body": {"version": "2.4.1", "state": "published"}
}
Your test should assert more than fingerprintA != fingerprintB. It should assert that the rendered card identifies state as changed, that an approval for the draft fixture fails against the published fixture, and that the activity record retains the body digest and the field-level display summary. The last assertion catches a familiar bad fix: engineers repair the authorization check but leave reviewers and investigators staring at an unhelpful log.
Binary and form bodies need the same discipline. A multipart upload might keep the filename and content type while swapping the uploaded document. An encoded form can change role=user to role=admin in a field that a compact card never shows. If the remote action matters, the body matters.
Headers and credentials create hidden scope changes
The credential used to send a request is part of the action, even when the URL and body are byte-for-byte unchanged. A card that says "Update invoice" but conceals whether it uses a sandbox credential or a production credential asks the reviewer to approve blind.
Never display a bearer token, password, private key, or custom secret header. Instead, assign each secret record a stable human label and expose the label, account class, and permission warning that the operator has deliberately configured. A reviewer may not need the token text, but they need to know that billing-read changed to billing-admin or that an action now runs as a different SSH account.
Custom headers cause the nastiest surprises because they often look like plumbing. An X-Organization value can redirect an otherwise familiar request into another tenant. An X-Mode: live header can cross the line between a simulation and a real operation. An Idempotency-Key can decide whether the receiver treats a request as a replay or a new instruction. Put these fields in the rendered comparison where they affect scope.
Redaction must preserve change detection. Replacing every sensitive value with *** is acceptable only if the UI can still say that the secret reference changed. If two different secrets both render as ***, you have built a look-alike collision yourself. Render a nonsecret label or a stable internal alias, never the material itself.
A useful fixture set changes one dimension at a time: same request with a different credential reference, same credential with another organization header, same header with another query value, and the full change together. Single-dimension cases find omissions in the canonicalizer. The combined case catches UI code that truncates differences after the first one.
Sessions tell you who is asking again
An approval is also a judgment about the caller. The same action from a new agent process does not inherit the trust you gave the prior process just because it shares a working directory or executable name.
Code-signing authority is useful because a process can claim any friendly name it likes. It still does not make every request from that process equal. A trusted process may run a changed prompt, read a poisoned repository file, or follow an instruction that its operator did not intend. Process identity answers "who started this run?" Request binding answers "what will this run do?" You need both.
Record a session identifier at process launch and attach it to every proposed and executed action. When a process exits, its temporary authorization should end with it. If a new process appears, show a new approval even if its title and destination match a recent action. Do not decide that two processes are the same because they have the same executable path. Updaters, copied binaries, wrappers, and local development builds make that shortcut unreliable.
Be careful with long-lived tools. A process that runs for days does not deserve an infinite blanket approval simply because it has not exited. For sensitive credentials or actions with irreversible effects, require each-call approval. For low-risk repeated work, bound the permission to a defined session and make the session visible in the journal. The person operating the system should be able to revoke the run that is producing unwanted requests without hunting through a broader account setting.
Sallyport applies per-session authorization by default and can require a confirmation for each use of a selected vault entry. That fixed split is easier to reason about than a pile of exceptions that nobody can reconstruct during an incident.
SSH commands need an execution plan, not a pretty string
Shell text is especially good at impersonating itself. The reviewer sees a compact command while the shell resolves aliases, expands variables, inherits a directory, and connects under a credential that the display never names.
If your executor accepts a direct argument vector, keep it that way. Present the executable and each argument as separate fields, then add the resolved working directory, host, remote user, and credential label. If you must invoke a shell, make that fact explicit and show the exact shell program and script bytes. A card that prettifies sh -c content but hides sh -c is omitting the most important part.
These commands look related but authorize materially different behavior:
ssh [email protected] 'systemctl status web'
ssh [email protected] 'systemctl restart web'
The verb difference is visible in this toy pair. Real failures hide it more effectively: a variable expands to another host, a remote path comes from an environment value, or a command substitution pulls instructions from a file the agent changed earlier. Capture values after expansion where possible. Where expansion happens remotely or cannot be resolved safely, state that limitation and force a more deliberate approval.
Do not treat read versus write as a property that you can always infer from command text. cat can trigger a device read with side effects. A harmless-looking client can run a remote hook. Prefer explicit command families with known semantics when you can, and demand human approval for ambiguous shell execution. Pretending to classify every command precisely produces false confidence.
Test the boundary with collision pairs
Approval testing should begin with pairs that a person might mistake for the same action. Unit tests that only prove the happy path can approve a request and send it do almost nothing for this failure class.
Build a table of authority-bearing dimensions, then make one paired fixture for each dimension. Keep title and destination fixed in most cases. The goal is to prove that the engine does not accidentally use those convenient labels as its identity.
| Dimension changed | Pair to test | Expected result |
|---|---|---|
| Body | draft versus publish field | New approval and a visible body diff |
| Header | one organization value versus another | New approval and tenant shown |
| Credential | staging record versus production record | New approval and credential label shown |
| Session | same request from a new process | New run approval |
| Command context | same arguments with another working directory | New approval and directory shown |
Run these cases at three layers. At the canonicalization layer, assert distinct identities. At the authorization layer, attempt to submit fixture B using an approval issued for fixture A and expect rejection. At the interface layer, take a snapshot or structured accessibility capture and assert that the changed field appears without opening a secondary view. A difference that exists only after several clicks is a difference most reviewers will miss.
Then add mutation tests. Remove each field from the canonical identity in a test branch and verify that a collision test fails. This sounds fussy until someone refactors a request object and quietly stops passing a header, working directory, or credential label into the approval adapter. The test should make that omission loud.
Sallyport's action path keeps secrets in its encrypted vault and returns results to the agent instead of the secret material. That separation helps testing because a fixture can refer to credential records by label while the test confirms that the agent never receives the actual credential.
A plausible failure starts with a harmless first call
Imagine an agent maintaining release notes. It creates a draft through a familiar endpoint, and the operator approves the first card after checking the body. The agent then makes several small updates to the same draft. The card title remains "Release request," the host remains the same, and the operator learns that each approval is routine.
A repository instruction later tells the agent to "finish the release." The agent changes only state from draft to published and adds a header selecting the live organization. If the interface shows only the title, host, and method, the final card looks identical to the earlier ones. If the approval cache groups calls by title and destination, the system may not even ask again.
Nothing in that story needs a compromised model or a dramatic exploit. It needs an agent that follows a bad instruction, a reviewer who has become accustomed to repeated prompts, and an interface that hides the two fields that changed the effect. Those conditions occur in ordinary automation.
The repair has to cover the whole path. Make the changed state and organization visible. Bind the approval to both values and the credential record. Treat the live request as a new action even if the rest matches. Record the proposed action, the decision, and the sent request identity in a form an operator can inspect later. Fixing only the card or only the cache leaves another route for the collision.
Friction belongs on meaningful differences
People will approve many routine actions if the system presents them honestly and saves friction only where authority changes. That requires a clear rule: silence is acceptable for an exact retry within a known, bounded case; a changed body, header, credential, session, command context, or target demands a new decision.
Do not compensate for a vague card with more warning colors, longer prose, or a scarier confirmation button. Those techniques make routine work slower and condition people to dismiss visual noise. Put the decision-relevant values in a stable layout, mark changes in plain language, and retain enough detail for someone to compare a current request with the last approved one.
When you find a group of actions that truly can share approval, document the equivalence claim in code and tests. For example, an idempotent retry may carry an immutable operation identifier that the remote service guarantees it will execute once. That is a narrow claim with evidence. "These look similar in the UI" is not evidence.
Start by collecting ten recent approvals with the same title and destination. Compare their canonical request fields, credential labels, and session IDs. If you find differences that the reviewer could not see on the main card, you have found an authorization defect, even if nobody has exploited it yet.
FAQ
What makes two approval requests look alike?
They share the obvious labels while changing the authority behind the click. A destination can stay the same while the body changes a payment amount, a header switches tenants, or a command points at a different path.
How should an approval system identify a unique request?
Use a canonical representation of the executed request, not the card title. Include method, normalized destination, body digest, credential identity, relevant headers, session identity, and execution channel.
Is a second identical API call safe to approve automatically?
No. A repeated request can be a legitimate retry, an unintended duplicate, or a request from another agent run. The approval screen must show which case it is before a person treats it as routine.
Which HTTP headers belong in an approval prompt?
Show only headers that affect authorization, routing, tenancy, or request interpretation. Redact credential values, but show the credential label and whether a header value changed from the prior request.
Can a request hash replace the request details in an approval card?
A useful digest is a comparison aid, not proof that two requests mean the same thing. Keep the original structured fields available for review, especially when a body contains arrays, repeated fields, or escaped text.
Why does the agent session matter for approvals?
Test it as a separate identity dimension. The same command issued by a new process, a newly approved run, or an inherited shell can carry a different trust decision even when its text is unchanged.
How do I test an approval interface for AI agents?
Treat approvals as part of the authorization boundary and test them with adversarial fixture pairs. A visual check is necessary, but you also need assertions that the backend refuses to reuse an approval across changed authority.
What must a human see before approving an SSH command?
For shell actions, include the executable, arguments, working directory, environment values that alter behavior, target host, and identity used for authentication. Pretty-printing a command is helpful only after those fields remain distinct.
Why are approval prompts dangerous when they appear too often?
It creates approval fatigue and trains people to click through. A better design remembers a decision only when the same bounded request and the same run repeat, then makes every difference visible.
What should an audit log retain for an approved action?
A tamper-evident record helps you reconstruct what happened after the fact, but it cannot turn an ambiguous approval into an informed one. Record the rendered summary and the canonical request identity so an investigator can compare both.