AI agents

Controlling What an AI Agent Is Allowed to Do

Agent permissions are an engineering problem, not a prompting one. Where to put the boundary, how to size the blast radius, and which controls still hold when the model is confidently wrong.

On this page
  1. What agent permissions actually are
  2. Why a system prompt is the wrong place to put a permission
  3. Scope by subtraction: arriving at the tool list
  4. Sort every action by whether you can undo it
  5. Approval gates, and the queue nobody staffs
  6. Identity, credentials and the confused deputy
  7. Blast radius: counters and the halt path
  8. Write the permissions down, then log what they allowed
  9. Terms worth being precise about
  10. Before you give an agent write access

What agent permissions actually are

The short answer

An agent's permissions are the set of tool calls it can make, the identity each call runs under, and the limits your code enforces on them. They are not lines in a system prompt. A prompt asks the model to behave. A permission stops the action when the model does not behave, which is what matters once the agent is reading text other people wrote. Every control worth having sits outside the model: the tool implementation, the credential it holds, and a counter the run cannot write to.

The usual question is how to stop an agent doing something. The better question is what it can physically do at all, because that part is code. A model can be talked around. A tool that takes two template identifiers and a recipient read from the ticket cannot. This guide assumes the work needs an agent; if that is open, read agent or workflow first.

  • 0tools an agent should start with, until a failing case argues for one
  • 1 identityper agent, never shared, or the log cannot say which system acted
  • 2 questionsbefore any grant: can it be undone, and would anyone notice
  • Every callpasses a layer that can refuse it, and that layer is your code

Why a system prompt is the wrong place to put a permission

A system prompt is a request and a permission is a refusal, and the gap is where incidents live. Everything an agent reads becomes input: retrieved documents, ticket bodies, web pages, file names, the text layer of an uploaded PDF. Any of it can hold a sentence shaped like an instruction, and the model cannot reliably tell yours apart.

Where the rule livesWhat enforces itWhat it stopsHow it is bypassed
System promptThe model, if the line is still in attentionNothing on its ownAn instruction inside retrieved content, or a transcript long enough to bury it
Tool schemaThe function-calling layer, looselyArguments of the wrong typeWell-formed arguments carrying the wrong values
Tool implementationYour code, on every callValues outside the allowed set, wrong tenantA second tool reaching the same resource
Credential scopeThe downstream providerEverything the token was never grantedAn over-broad token, issued because scoping was fiddly
Spend and rate budgetA counter outside the runRunaway loops, retries, bulk sendsA counter in memory, which resets on retry
Only the bottom three hold when the model is wrong or manipulated.
Rule in the promptRule in the tool
Who enforces itThe model, probabilisticallyYour code, every call
Under prompt injectionFollows the newest instructionRefuses, and the refusal is logged
After a model upgradeBehaviour moves on its ownUnchanged
Evidence for an auditorA transcript you hope reads wellA decision record with a policy version
How it failsSilently, and a customer tells youLoudly, at the call site
Injection is a permission problem, not a prompt problem

The fix for an injected instruction is that the instruction, once followed, reaches a tool that refuses. Assume every token the agent reads was written by somebody hostile, then ask what the worst reachable tool call is. That answer is your security posture. Classifier guardrails lower how often an attempt succeeds; the tool list sets the ceiling on damage.

Scope by subtraction: arriving at the tool list

Grant nothing, then earn each capability back against evidence. Most agents arrive with a tool list copied from a template and nobody can say which entries are load bearing. Subtraction pays twice, because long tool lists hurt accuracy as well as security.

Framework

Scope by Subtraction

Five moves that produce a tool list you can justify, and short enough that the model uses it well.

01
Start at zero tools

The first version answers from context alone and fails most of your cases. That failure list is the specification for the tool surface. Start from everything the API offers and you never learn which tools were decoration.

02
Add a tool only against a named failing case

A capability enters the manifest with the test that fails without it, in the same commit. A year later, one eval run answers whether removing it breaks anything.

03
Bind each tool to the narrowest identity

Separate credentials per tool, read scope by default. One over-broad token shared by every tool is the most common finding in an agent security review, and it turns a single tool bug into a full-account bug.

04
Cap the tool at construction, not at call time

Limits arrive as constructor arguments: records per run, recipients per message, currency ceiling. A limit passed as a call argument is one the model can argue past, because it is the caller.

05
Give every grant an expiry

Each permission carries a review date, and anything nobody re-justified in a quarter is removed. Agents accumulate tools the way a laptop accumulates browser extensions.

Sort every action by whether you can undo it

Classify actions by two checkable questions before you classify them by risk: can this be undone, and would anyone notice within an hour. Risk ratings drift between reviewers. These two have factual answers, and together they pick the control.

ActionUndoableNoticed within an hourControl that fits
Write an internal note or draftYesNoAllow, log every field written
Update a CRM fieldOnly if you stored the old valueRarelyAllow, log the old value first
Send an external emailNoYes, the recipient repliesApproval, or an allow list of templates and recipients
Issue a refund or move moneyNoAt reconciliation, not beforeApproval per item, per-run ceiling, second identity
Delete rows, close accounts, revoke accessNoUsually notDo not delegate it. Soft delete, and a job a person owns

The dangerous quadrant is irreversible and unnoticed. An agent writing wrong values into a field nobody reads until quarter end does more damage than one sending an odd email: the email draws a reply in minutes, the field error compounds through every report built on it. Rank what to gate by detection latency, not by how alarming the verb sounds. How agents fail covers detection.

Turn irreversible into reversible before you turn it into a gate

Soft deletes, drafts instead of sends, staged writes with a cancellation window, a compensating action for every write. Plenty of actions that teams put behind human approval could be made reversible for less effort than a year of staffing the review queue.

Approval gates, and the queue nobody staffs

Gate the irreversible actions, then do the arithmetic on the queue you just created, because an approval nobody has time to give is not a control. It becomes a backlog bulk approved on a Friday afternoon, which is worse than no gate: the audit trail now claims a human reviewed each one.

Approval load and irreversible exposure

Use your own numbers. Take the error rate from your eval set rather than from optimism.

0Irreversible actions per day
0Wrong irreversible actions per month, ungated
0Reviewer hours per week if you gate them all

Two things fall out of that. Review load scales with volume while the reviewer does not, so a gate on a high-frequency action has a shelf life measured in months. And halving the error rate does not halve the load, because correct actions still queue. If the hours are unstaffable, the fix is upstream.

  • Approve the policy, not the instance. A reviewer signs off a rule such as refunds under a stated ceiling on orders younger than thirty days, and the agent runs inside it.
  • Batch by similarity. Twenty near-identical actions on one screen with differences highlighted takes a fraction of the time, and the outlier gets spotted.
  • Make refusal cheap. If the only options are approve or kill the run, people approve. Give them edit, defer, and a route to the rule.

Identity, credentials and the confused deputy

An agent should never hold more authority than the person whose request it is serving. The failure has a name from operating systems: the confused deputy. A privileged process acts for an unprivileged caller without checking whether that caller was entitled to the result. An agent with a broad service token behind a chat box is that shape, and retrieval makes it worse, because the index usually spans every tenant.

Three rules that hold up under review

  1. Pass the caller identity into the tool layer and re-check authorization there against the caller. The agent's credential exists to reach the service; the caller's rights decide what comes back.
  2. Filter in the query, never in the prompt. WHERE org_id = :caller_org is a permission. Asking the model to discuss only the current organisation is a hope. Same for vector search: the tenant filter belongs in the retrieval call.
  3. Give each agent its own principal. A shared service account leaves the audit log unable to say which system acted, and widening a scope for one widens it for the others.
Shared inboxes are where this leaks

An agent triaging a shared mailbox usually ends up with a mailbox-wide credential, because that is the only grant the provider offers. It can then read every thread in there, including the ones about the people it is answering for. Decide which folders are in scope, enforce that in the fetch query, and record the exclusions in the manifest.

Blast radius: counters and the halt path

A budget is only a control if the thing being limited cannot write to the counter. Keep the ledger outside the agent process: a row keyed by run identifier, incremented by the tool wrapper before the call, read at the top of every later call. An in-memory counter dies with the process and restarts at zero on retry.

  1. Give every run an identifier and a ledger rowbefore the first tool call

    The identifier travels with every log line, tool call and record the run writes. Without it you cannot say what one run did, or stop one without stopping all.

  2. Count side effects, not only tokens

    Add counters for records written, messages sent and currency moved. A run that stayed inside its dollar budget while sending two hundred emails was not controlled.

  3. Halt on the counter, never on the model's judgment

    The check is a conditional in the tool wrapper that runs before the call and raises. Asking the model to track its own budget works until the transcript buries the line.

  4. Make the kill switch a value the process readschecked before every tool call

    One flag per agent and one global flag, read from a store with a short cache. If halting a misbehaving agent needs a pull request, it runs for the length of your CI pipeline.

  5. Decide what a halted run leaves behind

    A run stopped at step nine has already done eight things. Every write tool needs an idempotency key, every irreversible tool a compensating action. Nobody invents those calmly during an incident.

Sizing the budget is arithmetic. Take the cost of a well-behaved run, multiply by the step ceiling to get the cost of a run that hits every limit, then set the daily budget so a stuck loop cannot exhaust it before alerting notices. Costing an agent does that arithmetic.

Write the permissions down, then log what they allowed

Put the whole permission surface in one file the agent loads at startup. Permissions scattered through application code never get reviewed, because the reviewer has to hold five files in their head and approves rather than admit they lost track.

Permission manifestyaml
# agent-permissions.yaml
# One file per agent, reviewed like code. The loader refuses to start if
# the codebase exposes a tool this file does not list.

agent: support-triage
policy_version: 7

identity:
  principal: svc-support-triage    # its own account, no other agent shares it
  acts_as: caller                  # tools check the caller's rights, not the agent's
  secrets: vault://agents/support-triage

budget:
  per_run: { usd: 0.40, tool_calls: 25, wall_clock_seconds: 90 }
  per_day: { usd: 60, external_messages: 400 }
  on_exceeded: halt_and_page       # halt, close the ledger, page on-call

tools:
  - name: search_tickets
    scope: read
    filter: "org_id = {caller.org_id}"   # in the query, not in the prompt

  - name: issue_refund
    scope: write
    reversible: false
    limits: { per_run: 1, usd_max: 50 }
    recipient: "order.payment_method"    # a field on the record, never free text
    approval: human
    approval_expires_seconds: 3600
    second_identity: finance-approver
    compensating_action: reverse_refund_v1

denied:
  - delete_ticket    # soft delete only, run by a job a person owns
  - "*"              # default deny, enforced by the loader

Three details carry the weight. acts_as: caller prevents the confused deputy. Binding the recipient to a field on the record rather than a free-text argument leaves an injected instruction nowhere to redirect the money. And on_exceeded: halt_and_page stops a loop spending the daily budget at three in the morning. The "*" under denied makes default deny something the loader enforces.

Decision record, one per tool calljson
{
  "run_id": "run_01J9KQ7Z8M",
  "step": 7,
  "policy_version": 7,
  "caller": { "user_id": "u_8812", "org_id": "org_44" },
  "tool": "issue_refund",
  "decision": "allowed_after_approval",
  "rules_matched": ["tools.issue_refund.limits.usd_max"],
  "approval": {
    "approver": "u_1093",
    "shown_to_approver": "sha256:2f1c9d...",
    "payload_at_execution": "sha256:2f1c9d..."
  },
  "arguments_redacted": { "usd": 24.5, "order_id": "o_55219" },
  "budget_after": { "usd": 0.11, "tool_calls_left": 18 },
  "reversal": { "possible": false, "compensating_action": "reverse_refund_v1" }
}

The field people leave out is shown_to_approver. An approval is worthless as evidence if you cannot show what was on the screen at the moment of the click, and arguments can change between approval and execution when a run is resumed. Hash the rendered payload, compare before executing, treat a mismatch as void. Log refusals too: a rising refusal rate is either a scope too tight or somebody probing. Auditable agent decisions covers replay and retention.

Terms worth being precise about

Definitions
Least privilege
Every component holds the smallest set of permissions that lets it finish its job, and holds them only while the job runs. For an agent that means per-tool credentials scoped read-only by default, not one token shared by the loop.
Blast radius
The full set of records, accounts and money an agent could affect if every decision in one run were wrong. It is a property of the tool surface and the credentials, not of the model, so it can be calculated before anything is built.
Confused deputy
A privileged process that acts for a less privileged caller without checking whether that caller was entitled to the result. An agent with a broad service token behind a user-facing interface is one, unless its tools re-check the caller's rights.
Default deny
Any capability not explicitly listed is refused, enforced by the loader rather than by convention. The test is whether adding a function to the codebase gives the agent a new ability with nobody editing the permission file.
Dry run
An execution mode in which every tool reports what it would have changed and changes nothing, producing a diff a person can read. It is the only safe way to widen a scope.

Before you give an agent write access

Run this before the first write reaches production, and again after any change that adds a tool or widens a scope. Every item can be verified by opening a file or running a command. Do it with whoever will be on call.

Permission readiness
0 of 10 done

If several are missing, the gap is rarely the model. Permissions got treated as hardening scheduled after the demo rather than as part of tool design, and retro-fitting them means rewriting tool signatures. Our evaluation and guardrails work exists to stop that ordering.

Questions readers ask next

Can I just tell the agent in its system prompt not to do something?
No, and treating that as a control is the most common design error in production agents. A system prompt influences behaviour, it does not constrain it, and instructions compete for attention with everything else in context, including text that arrived through retrieval. Put the rule in the tool implementation, where a conditional either allows the call or raises.
What is the difference between agent permissions and ordinary API permissions?
The mechanism is the same and the caller is different. Ordinary API permissions assume a caller whose behaviour is deterministic and whose inputs are trusted. An agent picks its own calls, in an order nobody specified, from text an adversary may have written. That adds per-run budgets, classification by reversibility, and a decision record for every call.
Should an agent use its own service account or act as the user?
Both, for different jobs. The agent needs its own principal so the audit log can name which system acted, and so a scope can be widened for one agent without widening it for others. Authorization for the data it touches should still be evaluated against the requesting user, inside the tool layer.
What is the safest way to give an agent write access to a production database?
Do not give it table access at all. Expose narrow operations instead: a function that updates three named fields on one record type, with the tenant filter inside the query and the previous values logged before the update. Add a per-run cap on rows touched, an idempotency key on every write, and soft deletes only.
Do guardrail models replace permissions?
No. A guardrail classifier reduces how often a bad instruction gets through, which is useful, but it is another probabilistic component sitting in front of the same tools. It cannot change the worst case, because the worst case is whatever the tool layer will execute. Use classifiers to cut noise, and tool enforcement to set the ceiling.
Cite this

ChatGPTalker. "Controlling What an AI Agent Is Allowed to Do." chatgptalker.com, 2026-08-26. https://chatgptalker.com/guides/agent-permissions-and-scope/

Rather have it built than read about it?

Send the process you want automated. You get a scoped plan back, with the build shape, the stack and a realistic timeline.

Start a project