AI agent security

How do I prevent an AI agent from taking irreversible actions?

Detection is how you classify the irreversible set. Enforcement is block or queue. A human click is a hold after allow, not the policy. A correlation ID reconstructs the run; it does not deny step 3 because of steps 1 and 2.

5 min read
In short: Detection is how you classify the irreversible set. Enforcement is block or queue. A human click is a hold after allow, not the policy. A correlation ID reconstructs the run; it does not deny step 3 because of steps 1 and 2.

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:

ClassExamplesDefault
TransferStripe charge, refund above a threshold, bank-detail changeDeny, or queue for a person
DeleteCustomer record, production table, published objectDeny unless a named runbook allows this object
SendEmail, SMS, public GitHub comment, Slack to a customerDeny unless the recipient is on an allowlist
Prod configFeature flag, DNS, IAM, billing plan, production deployDeny 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.