AGT-21Required
Confirm risky actions with a token bound to the exact request
An agent call that pays, deletes, sends to many people or cannot be undone first returns a confirmation token. Only a second call with that token, the same arguments and the person's yes carries it out.
How to do it
The client's own approval step is outside your control, so enforce the pause on the server. Return a preview and a token tied to the user, the tool and a hash of the arguments. Let it expire after 10 minutes. Get the person's yes through an input_required round, as Sign the state you hand back for a second round says, or through your own app. Refuse a token used twice, used by someone else or used with changed arguments. Set the money and recipient thresholds in one place, and keep them below the caps in Cap what each agent grant may spend. The client side of the same idea is Ask a person before the agent spends or deletes.
Examples
JavaScript example
import { createHash, randomUUID } from 'node:crypto';
const CONFIRM_TTL_MS = 600_000;
const argsHash = args => createHash('sha256').update(JSON.stringify(args, Object.keys(args).sort())).digest('hex');
export async function confirmOrPreview(tool, args, user, preview, run) {
const { confirmation, ...rest } = args;
if (!confirmation) {
const id = randomUUID();
await confirmations.insert({ id, userId: user.id, tool, hash: argsHash(rest), expiresAt: Date.now() + CONFIRM_TTL_MS });
return { needsConfirmation: true, confirmation: id, preview: await preview(rest) };
}
const used = await confirmations.consume(confirmation, { userId: user.id, tool, hash: argsHash(rest), now: Date.now() });
if (!used) return { isError: true, content: [{ type: 'text', text: 'Confirmation is missing, expired or for other arguments. Ask again.' }] };
return run(rest);
}
Python example
import hashlib, json, time, uuid
CONFIRM_TTL_SECONDS = 600
def args_hash(args: dict) -> str:
return hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()
def confirm_or_preview(tool: str, args: dict, user, preview, run):
rest = {key: value for key, value in args.items() if key != "confirmation"}
if "confirmation" not in args:
token = str(uuid.uuid4())
confirmations.insert(id=token, user_id=user.id, tool=tool, hash=args_hash(rest), expires_at=time.time() + CONFIRM_TTL_SECONDS)
return {"needsConfirmation": True, "confirmation": token, "preview": preview(rest)}
if not confirmations.consume(args["confirmation"], user_id=user.id, tool=tool, hash=args_hash(rest), now=time.time()):
return {"isError": True, "content": [{"type": "text", "text": "Confirmation is missing, expired or for other arguments. Ask again."}]}
return run(rest)
How to check
A test calls refund_order without a token and gets a preview, not a refund. A second call with the token and a changed amount is refused. The same token used twice refunds once. A token older than 10 minutes is refused.
Sources
- OWASP Top 10 for LLM Applications, LLM06 Excessive Agency
- MCP specification on tools (user interaction model)
Related rules
- AGT-18 Sign the state you hand back for a second round in Agents as users
- AGT-25 Cap what each agent grant may spend in Agents as users
- SEC-82 Ask a person before the agent spends or deletes in Adversarial testing
- AGT-22 Return an undo handle with every agent change in Agents as users
For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/agents-as-users/safe-actions/confirmation-token">Agent Wranglers agents as users playbook</a>. In code you write, add a comment next to it with the same link.