How do I limit what actions an AI agent is allowed to take?
To limit what actions an AI agent can take, enumerate the actions, deny the set by default, and make the allow decision inside each tool handler, where the user, the tenant, and the arguments are all in scope. The tool list in the agent configuration decides which functions the model can call. It doesn't decide whether a given call runs.
Those are two different limits, and most teams ship only the first one.
The registration limit is static. tools: [lookupOrder, issueRefund] says that issueRefund exists in this agent's vocabulary. It says nothing about the invoice, the amount, the caller, or how many refunds already ran this hour. A model that an injected instruction has redirected toward a plausible refund produces a well-formed call to a tool that it was legitimately given.
The runtime limit is per call. It runs in the handler, immediately before the side effect, with the values that matter: the authenticated session, the tenant, the parsed arguments, and whatever counters you keep. That limit holds when the model is wrong.
Arcjet is built for the runtime limit. It installs as a library and evaluates its rules in your own process rather than at a network hop, so its guard() call returns an allow-or-deny decision inside the tool handler, before the side effect. For more information about why that placement matters, see AI agent runtime security.
How do I enforce least-privilege for AI agent tool calls?
Least privilege for an AI agent has four dimensions, and a tool allowlist covers only the first one:
- Which tools. The set of functions that this agent can call at all.
- Which objects. The rows, tenants, repositories, and accounts that each call can touch.
- Which arguments. The bounds on amounts, environments, recipients, and ranges.
- How often. The rate at which an allowed action can repeat.
An agent that can call issueRefund for any invoice, for any amount, without limit, isn't operating under least privilege, even though the tool list has one entry.
Derive the object scope from the session, not from the model. The model proposes an invoiceId. Your handler resolves that invoice and checks that it belongs to the tenant on the session. If the agent supplies the tenant, then the boundary is decorative.
The following handler scopes the lookup and bounds the amount:
export async function issueRefund( args: { invoiceId: string; amountCents: number }, session: { userId: string; tenantId: string; role: string },) { // The tenant comes from the session, not from the model's arguments. const invoice = await invoices.find({ id: args.invoiceId, tenantId: session.tenantId, });
if (!invoice) { return { error: "Invoice not found" }; }
if (args.amountCents > invoice.refundableCents) { return { error: "Amount exceeds the refundable balance" }; }
return stripe.refunds.create({ charge: invoice.chargeId, amount: args.amountCents, });}Two properties of that handler do the work. The lookup is scoped by the tenantId from the session, so a cross-tenant invoiceId returns nothing rather than a refund. The amount is bounded by a server-side value, so a model that asks for more than the invoice can refund gets a rejection rather than a negotiation.
Role is the fourth check, and it belongs on the action rather than on the agent. The same agent code reaches different answers for a support engineer and for an administrator, because the session differs.
For more information about representing the user's authority through a delegation chain, see AI agent on-behalf-of identity.
How do I stop AI agents from taking unsafe or unauthorized actions?
Unsafe and unauthorized agent actions are different failures, and they need different controls.
An unauthorized action is one that the caller had no right to. Authorization is deterministic: this user, this object, this role. It's an application-authorization problem that a model happens to trigger, and the checks in the preceding section solve it.
An unsafe action is one that was permitted but that shouldn't have run in this context. A refund that the user is entitled to request, issued 50 times. A production flag change that's within the engineer's role but lands during an incident. A support email to a real customer that contains another customer's order history. No role check catches those, because the role allows each one.
The following table maps each failure to the control that stops it:
| Failure | Example | Control |
|---|---|---|
| Unauthorized object | Refund on another tenant's invoice | Scope the lookup by the session's tenant |
| Unauthorized action | Support role issuing a refund over its limit | Role check in the handler, on the parsed arguments |
| Unsafe repetition | The same approved refund, 50 times | A token bucket on the tool, keyed on the user or account |
| Unsafe content | An outbound message carrying another customer's data | A sensitive-info check on the argument before the send |
| Unsafe irreversibility | A production delete with no undo | Deny by default, hold for a person |
The repetition row is the one that teams skip. An HTTP rate limit on the chat route counts workflow starts. One start can fan out into 50 tool calls, and the route limit doesn't count them. The counter has to sit where the action runs.
The following example does that with Arcjet, which installs as a library and evaluates its rules in your own process rather than at a network hop. launchArcjet creates the client, the token bucket and the sensitive-information rule are configured once when the module loads, and arcjet.guard() evaluates both against one refund call and returns a single allow-or-deny decision. The protected action is a refund, limited to five per user per hour and screened for card numbers in the free-text memo:
import { launchArcjet, localDetectSensitiveInfo, 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 memoCheck = localDetectSensitiveInfo({ deny: ["CREDIT_CARD_NUMBER"] });
export async function guardRefund( args: { invoiceId: string; amountCents: number; memo: string }, session: { userId: string; runId: string },) { const decision = await arcjet.guard({ label: "tools.issue-refund", actor: session.userId, correlationId: session.runId, rules: [ refundFrequency({ key: session.userId, requested: 1 }), memoCheck(args.memo), ], });
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("Refund denied"); }}The sensitive-info rule runs locally, so the memo text doesn't leave your process to be classified. For more information about that property, see keeping security inspection local.
What isn't an action limit?
Four controls are routinely mistaken for an action limit.
A system prompt instruction. A prompt line such as Never issue a refund above $500 is a suggestion to a text generator. An injected instruction overrides it first, and the model has no mechanism to enforce it.
A framework allowlist of tool names. allowedTools and its equivalents answer whether this agent can ever call this function. They run before the arguments exist. For more information about that gap on Claude hosts, see canUseTool is not a policy gate.
Human approval on everything. Parking every call in front of a reviewer produces a reviewer who clicks approve. Keep the held set small enough that a person reads it. For more information, see human approval is not a security policy.
A sandbox. Process isolation stops the agent from reading /etc/passwd. It doesn't stop a correctly sandboxed process from calling the Stripe API that you gave it credentials for. For more information, see a sandbox is not a tool policy.
What happens when the check can't finish?
Decide the failure behavior per action, not globally. A search tool can fail open, so that a detector timeout doesn't blank results for every user. A transfer must fail closed, so that a timeout doesn't move money without a decision.
Arcjet's direct guard() call returns ALLOW when it couldn't finish evaluating, and hasFailedOpen() reports that. On a reversible read, ignore it. On a refund, treat it as a deny, which is why the preceding sample checks both.
One global fail-open setting shared by a search endpoint and a refund endpoint is a decision that you made by not making it.
The limit this doesn't reach
Individually valid actions in a harmful sequence remain the hard case. Read the customer record, then draft a summary, then send it to an address that the agent found in the record: each call passes its own check, and the sequence is the incident.
A correlation ID lets you reconstruct that chain afterwards. It doesn't deny step three because of steps one and two. Start by writing explicit checks for the dangerous combinations that you can name in your own application, and don't assume that a product has solved this because its marketing says so. For more information, see anatomy of an agent incident and runtime controls for agents on enterprise systems.
Where Arcjet fits
Arcjet works on the runtime limit. It installs as a library and runs its rules inside the tool handler, so the frequency limit, the sensitive-information check, and the audit record arrive as one allow-or-deny decision before the side effect, with the session identity that you already have. The tenant-scoped lookup and the role check stay in your code, because Arcjet doesn't replace your authorization logic.
Guards are the in-handler decision, and every decision is recorded as a capture event with the actor and the correlation ID, which is the evidence that the checklist asks for.
Learn more: Arcjet Guards · AI runtime protection
Action limit checklist
- Every tool handler resolves objects with an identifier from the session, not from the model.
- Every numeric argument is bounded by a server-side value.
- Role is checked in the handler, on parsed arguments, not on the agent as a whole.
- Irreversible actions are deny-by-default with a named path for the exceptions.
- Every allowed action that can repeat has a frequency limit keyed on the user or account.
- Free-text arguments that leave the system are screened before the send.
- Fail-open and fail-closed are chosen per action and asserted in a test.
- Each decision records the actor, the action, and the correlation ID.
Frequently asked questions
How do I limit what actions an AI agent is allowed to take?
Enumerate the actions, deny the set by default, and make the allow decision inside each tool handler. The tool list in the agent configuration decides which functions the model can call. The handler decides whether this call, with these arguments, for this user, runs.
How do I enforce least-privilege for AI agent tool calls?
Least privilege has four dimensions: which tools, which objects, which arguments, and how often. A tool allowlist covers only the first. Derive object scope from the session, bound numeric arguments with server-side values, and rate-limit every action that can repeat.
How do I stop AI agents from taking unsafe or unauthorized actions?
Unauthorized means the caller had no right to the action, and a role and tenant check in the handler stops it. Unsafe means the action was permitted but shouldn't have run in this context, which needs frequency limits, content checks on outbound arguments, and deny-by-default on irreversible actions.
Is a system prompt instruction an action limit?
No. A prompt line such as "never refund above $500" is a suggestion to a text generator with no mechanism to enforce it, and an injected instruction overrides it first. Put the threshold in the tool handler instead.
Does an agent action fail open or fail closed?
Decide per action. A search can fail open so that a detector timeout doesn't blank results. A transfer must fail closed so that a timeout doesn't move money without a decision. One global setting shared by both is a decision made by omission.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Arcjet guards run inside the tool, so the allow or deny arrives before the side effect rather than after it.