On this page
- What agent permissions actually are
- Why a system prompt is the wrong place to put a permission
- Scope by subtraction: arriving at the tool list
- Sort every action by whether you can undo it
- Approval gates, and the queue nobody staffs
- Identity, credentials and the confused deputy
- Blast radius: counters and the halt path
- Write the permissions down, then log what they allowed
- Terms worth being precise about
- Before you give an agent write access
What agent permissions actually are
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 lives | What enforces it | What it stops | How it is bypassed |
|---|---|---|---|
| System prompt | The model, if the line is still in attention | Nothing on its own | An instruction inside retrieved content, or a transcript long enough to bury it |
| Tool schema | The function-calling layer, loosely | Arguments of the wrong type | Well-formed arguments carrying the wrong values |
| Tool implementation | Your code, on every call | Values outside the allowed set, wrong tenant | A second tool reaching the same resource |
| Credential scope | The downstream provider | Everything the token was never granted | An over-broad token, issued because scoping was fiddly |
| Spend and rate budget | A counter outside the run | Runaway loops, retries, bulk sends | A counter in memory, which resets on retry |
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.
Scope by Subtraction
Five moves that produce a tool list you can justify, and short enough that the model uses it well.
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.
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.
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.
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.
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.
| Action | Undoable | Noticed within an hour | Control that fits |
|---|---|---|---|
| Write an internal note or draft | Yes | No | Allow, log every field written |
| Update a CRM field | Only if you stored the old value | Rarely | Allow, log the old value first |
| Send an external email | No | Yes, the recipient replies | Approval, or an allow list of templates and recipients |
| Issue a refund or move money | No | At reconciliation, not before | Approval per item, per-run ceiling, second identity |
| Delete rows, close accounts, revoke access | No | Usually not | Do 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.
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.
Use your own numbers. Take the error rate from your eval set rather than from optimism.
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
- 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.
- Filter in the query, never in the prompt.
WHERE org_id = :caller_orgis 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. - 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.
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.
- Give every run an identifier and a ledger row
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.
- 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.
- 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.
- Make the kill switch a value the process reads
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.
- 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.
# 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.
{
"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
- 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.
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?
What is the difference between agent permissions and ordinary API permissions?
Should an agent use its own service account or act as the user?
What is the safest way to give an agent write access to a production database?
Do guardrail models replace permissions?
ChatGPTalker. "Controlling What an AI Agent Is Allowed to Do." chatgptalker.com, 2026-08-26. https://chatgptalker.com/guides/agent-permissions-and-scope/