How do I prevent an AI agent from taking irreversible actions?
The actions you can't undo from your app are the ones that move money, delete a record, send a message someone already read, or change production. Put those on a deny-by-default list. A person can hold the few that still need to run. A token bucket stops the fiftieth approved refund. Confirm at the Stripe call, not in the system prompt.
Human-in-the-loop is a hold after a policy allow. It isn't the policy. For more information about that split, see human approval is not a security policy and human approval gates for agent actions.
What counts as irreversible
An action is irreversible when reversing it needs another organization, a backup you may not have, or a person who already saw the message. The following table classifies the common cases:
| Class | Examples | Default |
|---|---|---|
| Transfer | Stripe charge, refund above a threshold, bank-detail change | Deny, or queue for a person |
| Delete | Customer record, production table, published object | Deny unless a named runbook allows this object |
| Send | Email, SMS, public GitHub comment, Slack to a customer | Deny unless the recipient is on an allowlist |
| Prod config | Feature flag, DNS, IAM, billing plan, production deploy | Deny in the agent. Use your change-management path |
Reads, drafts, and staging writes are reversible. Don't put them on the same list. A list that includes lookupOrder trains reviewers to click through. Keep the gated set small enough that a person reads it.
Detection is how you classify: a static allowlist of tool names plus rules on arguments (amount_cents > 10_000, environment === "production", to not in allowedRecipients). Enforcement is block or queue. Don't wait for a model to "notice" that a transfer is serious.
Deny by default on that set
The tool handler is the last reversible point. A framework allowlist of tool names answers "may this agent ever see issue_refund?" It doesn't answer "this user, this invoice, this amount, on this request." For more information about that mistake on Claude hosts, see canUseTool is not a policy gate.
const IRREVERSIBLE = new Set([ "issue_refund", "delete_customer", "send_email", "set_prod_flag",]);
export async function runTool( name: string, args: Record<string, unknown>, user: { id: string; role: string },) { if (IRREVERSIBLE.has(name) && user.role !== "finance-approver") { return queueForHuman({ name, args, userId: user.id }); } return tools[name](args, user);}queueForHuman stores the intent and doesn't call Stripe. Store the arguments you validated, not the raw model output, and store them server-side: the resumed call has to read the amount from that record rather than from whatever the agent sends when it retries. The hold is yours to build. For more information about the policy decision, hold, and resolution, see human approval gates.
Rate-limit how often an allow can fire
A correctly approved refund, 50 times, is still an incident. Put a token bucket on the irreversible tool, keyed on the user or the account, at the handler. An HTTP limit on the chat route counts workflow starts, not refunds.
if (!(await refundBucket.take(user.id, 1))) { throw new Error("Refund frequency exceeded");}For more information about keying a budget the same way, see enforce token and spend budgets.
Dry-run and approve at the API call
For transfers and sends, require an approval the model can't satisfy by talking:
async function issueRefund(args: { invoiceId: string; amountCents: number; approvalId?: string;}) { const approval = args.approvalId ? await approvals.find(args.approvalId) : undefined;
if (!approval || approval.status !== "approved") { return { preview: true, invoiceId: args.invoiceId, amountCents: args.amountCents, hint: "This refund needs an approval before it runs", }; }
// The approval record supplies the amount. If the model can change the // number between the review and the call, the review covered nothing. return stripe.refunds.create( { charge: chargeIdFor(approval.invoiceId), amount: approval.amountCents, }, { idempotencyKey: `refund:${approval.id}` }, );}The approval is a row you look up, not a boolean the model passes. A confirm: true argument is satisfied by a model that guesses the field name.
The idempotency key matters more than it looks. A held action gets retried: the queue redelivers, the reviewer double-clicks, the worker restarts after the Stripe call but before the local write. Keying on the approval ID means those all collapse into one refund, and Stripe replays the original response instead of moving money twice. Without it, "resume the approved action" is a duplicate-payment bug waiting for a bad deploy.
Staging can set a dry-run environment that logs the Stripe payload and returns an HTTP 204 status code.
What a correlation ID doesn't do
A correlation ID lets you reconstruct "read customer, then refund" after the fact. It doesn't deny the refund because of the earlier read. Sequence-aware deny isn't something this document implements. For more information about the sequence problem, see anatomy of an agent incident and runtime controls on enterprise systems.
A sandbox that isolates code doesn't authorize issue_refund. For more information about that gap, see a sandbox is not a tool policy.
One in-process deny on the transfer tool
The following Guard sits in issue_refund before Stripe. It is a per-user frequency cap. Classification (IRREVERSIBLE, the human queue) stays in your code. guard() takes no Request. It has no bot primitive.
import { launchArcjet, tokenBucket } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const refundFrequency = tokenBucket({ bucket: "issue-refund", refillRate: 5, intervalSeconds: 3_600, maxTokens: 5,});
const decision = await arcjet.guard({ label: "tools.issue-refund", actor: session.userId, correlationId: workflowRunId, rules: [refundFrequency({ key: session.userId, requested: 1 })],});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { // A direct guard() call returns ALLOW when it couldn't finish evaluating. // On a refund, treat that as a deny. throw new Error("Refund denied");}
await queueForHuman({ name: "issue_refund", args: { invoiceId, amountCents }, userId: session.userId,});Scan free-text memos if the refund tool accepts them. Don't scan the invoice id. For more information about outbound SaaS sends (GitHub, public email APIs), see guardrails on agents that call external APIs.
Frequently asked questions
How do I prevent an AI agent from taking irreversible actions?
Classify transfers, deletes, sends, and production config changes. Deny that set by default. Queue a human for the few that must proceed. Rate-limit how often an allow can fire. Resume from a stored approval record and send an idempotency key, not from a boolean the model passes.
Is human approval enough to stop irreversible agent actions?
No. A click is a hold after a policy allow. Fatigue turns a large queue into an allowlist. Keep the gated set small. The policy still has to deny or allow in code. For more information about the hold pattern, see the two human-approval pages.
Does a correlation ID stop a later irreversible step?
No. It reconstructs the run so that you can read the sequence afterward. It doesn't deny step 3 because of steps 1 and 2. Write explicit application checks for the dangerous combinations that you actually have.
Where should the deny sit?
Inside the tool handler, immediately before Stripe, the delete, or the send. A framework allowlist of tool names isn't a policy on these arguments. A sandbox isn't a tool policy either.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.