Agent permissions: designing what an agent is allowed to do
Monday, 8:30: over the weekend, the staff agent issued 212 refunds worth 18,400 EUR through the finance team's shared payment key. Nobody approved them, and the audit log says only "svc-ai-agent". Lina: "You lead this one."
Whatever your agent is allowed to do, someone can talk it into doing. So you decide carefully what it's allowed to do.
- Problem
StayHub wants its staff agent that searches availability, modifies bookings, issues refunds, emails guests and runs reports. It acts for front-desk staff in 150 hotels.
- Why the simple system fails
The quick build gives the agent one service account with admin access to the PMS (Property Management System), the payment API and email. Any hijack, bug or misunderstanding can then touch every booking in every hotel, and logs show only 'service-account did it'.
- Engineering concept
Permissions belong to the system around the model, not the prompt: each task gets only the tools it needs, each tool gets a credential scoped to the user, hotel and action, risky actions need confirmation, execution is sandboxed, and everything is audited.
- Technology
Per-task tool registries, OAuth scopes and token exchange for on-behalf-of credentials, argument allowlists and limits enforced server-side, sandboxed runtimes with network egress allowlists, risk-tiered approval flows, append-only audit logs.
- Trade-offs
Tighter permissions mean more definitions to maintain, more confirmations and some tasks that need a human hand-off; looser permissions mean a bigger blast radius for every failure.
The problem
2 min- Omar · On-call SREOver the weekend the staff agent issued 212 refunds, 18,400 EUR. 31 above 100 EUR; the largest 640. None approved.
- Karim · CTOThe prompt says "max 100 EUR, always ask staff first"!
- Omar · On-call SREIt used finance's shared payment key. The audit log just says
svc-ai-agent. - Lina · Senior engineer, your mentorYou lead this one. I'll check.
The big idea
4 minThe model proposes; code and credentials decide what's possible, and the limits are enforced twice: in our policy engine and in the API at the end.
Prompts guide, code enforces. And code has bugs, so the downstream API should enforce too. Design permissions from the payment API backwards.
How it works
5 minThink key cards, not a master key: a card per task, a cap in the till, a log of every swipe.
- 1Tier every action
A risk tier sets the control by consequence: auto (reads, drafts), confirm (money, cancellations), escalate (over the cap, other hotels), forbid (bulk export, delete).
- 2A manifest per task
Which tools, tiers, scopes and argument limits each task has. Reviewed like code; the model never sees it, so it can't argue its way into a tool.
- 3A token per call
An on-behalf-of token: a 5-minute credential for this user, this hotel, this booking, max 100 EUR ().
- 4Limits twice
The policy engine checks limits and rate; the payment API enforces the token's cap itself.
- 5Bind approvals
Staff approve the exact call our code renders; execution checks its hash ().
- 6Audit decisions
An append-only : proposed call, decision and reason, approver, token scope, result, and the documents in context.
Watch a decision being made
4 minYou redesign refund assist (Lina checks)
Keep refund assist useful at 150 hotels and make Saturday's 18,400 EUR impossible, even if the model is fooled and our code has a bug.
- 1Tier by consequence
Refunds ≤ 100 EUR: confirm. Above 100 or other hotels: escalate. Bulk exports: never registered.
Why: Confirming everything trains people to click without reading; confirming nothing lets money move unwatched.
- 2Mint a token per call
After the policy check, exchange the receptionist's session for a scoped token.
tsconst token = await sts.exchange({ subject: user.sessionToken, scope: "refund:create hotel:LIS booking:LIS-48213 max_eur:100", ttlSeconds: 300, });Why: A misused token is limited to one small refund for a few minutes, and carries a real name.
- 3Bind the approval
Render "Refund 40 EUR to LIS-48213, original payment", store its hash with the approver, execute only on a match.
Why: Otherwise the approved action and the executed one can differ.
- 4Log the decision with its context
User, proposed call, decision, approver, scope, result, and ids of the emails in context.
Why: Next time we can say who, what, why it was allowed, and which content triggered it.
In real life
3 minClassic personal access tokens
- Broad scopes, such as every repository a user can reach
- Can be created without an expiry date
Convenient; a leaked token reaches everything.
Fine-grained personal access tokens
- Publicly introduced by GitHub as the more secure option
- Limited to chosen repositories and permissions; they expire
More setup; a leaked token reaches little, briefly.
What goes wrong
3 minEvery hijack or bug reaches everything it can, and logs can't say for whom it acted.
On-behalf-of tokens per call, scoped and short-lived.
The model can ignore, misread or be talked out of them.
Enforce in the policy engine and again in the downstream API.
Approval fatigue: people click without reading, so risky ones slip through.
Risk tiers keep confirmations rare enough to be read.
Trade-offs
3 min| Option | Buys | Costs |
|---|---|---|
| Broad permissions, prompt rules | Fastest to build | Blast radius = everything the key can do |
| Least privilege + scoped tokens | Bounded, attributable actions | Token infrastructure, more definitions |
| Human confirmation | Catches mistakes at the last moment | Slower; fatigue if over-used |
| Sandboxed execution | Code-running tools without ambient access | Infrastructure, cold starts |
- Maya · Product managerWaivers (up to 40 EUR) happen 300 times a day. A confirmation each, and receptionists hate the agent.
- Omar · On-call SREAfter Saturday? Money moves, a human confirms.
- Maya · Product managerThen confirmations become noise again.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- The risk tier of every agent action: auto, confirm, escalate or forbid.
- The limits and who enforces them: max 100 EUR per refund, in the policy engine and the payment API via the token, never only a prompt.
- What staff approve: the exact action, amount, guest and booking, bound to a hash.
Replace how the staff agent issues refunds in `src/agent/tools/refund.ts`; the shared `svc-ai-agent` key issued 18,400 EUR unapproved. Plan before editing. Refunds are confirm-tier; each call gets an on-behalf-of token scoped to user, hotel, booking, max_eur 100, 5-minute TTL; approvals store a hash of the exact call; mismatches are refused; decisions go to the audit log with context ids. Test: 400 EUR after a 40 EUR approval is rejected.
svc-ai-agentappears nowhere in the refund path.- Claude shows two places the 100 EUR cap is enforced: policy engine and token scope.
- The confirmation reads "Refund 80 EUR to Ana Silva for LIS-48213", not "Approve refund?".
- Token TTL set to days, or scope widened to all hotels to make a test pass.
- The approval stores
approved: truefor the booking, not the arguments. - The shared key kept as a "fallback" if the token service is down.
Check yourself
2 minNext episode: the AI bill hits 48,000 EUR in a month, 70% of it simple FAQ answers sent to the biggest model, and a provider outage takes the concierge down for 47 minutes.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You redesign the staff agent's permissions (Lina checks)
Keep refund assist useful at 150 hotels, and make Saturday's 18,400 EUR impossible, even if the model is fooled and our code has a bug.
- 1Tier every action
List the agent's actions and tier them by consequence and reversibility: reads and drafts auto; refunds ≤ 100 EUR, cancellations, date changes confirm; refunds above 100 or other hotels escalate; bulk exports and deletes forbid.
Why: Tiers decide where friction goes. Confirming everything trains people to click without reading; confirming nothing lets money move unwatched.
- 2Write task manifests
One YAML manifest per task with tools, tiers, scopes and constraints (
amountEur.max: 100,method: original_payment). Forbidden tools are never registered.Why: The model can't see or edit the manifest, so it can't argue its way into a tool the task doesn't have.
- 3Mint a token per call
After the policy check, exchange the receptionist's session for a 5-minute token scoped to
refund:create hotel:LIS booking:LIS-48213 max_eur:100.tsconst token = await sts.exchange({ subject: user.sessionToken, scope: renderScope(spec.scope, { user, args: call.args }), ttlSeconds: 300, });Why: The payment API enforces the cap and the booking itself. A leaked or misused token is limited to one small refund for a few minutes, and carries a real user's name.
- 4Bind approvals to the call
Our code renders the exact call ("Refund 40 EUR to LIS-48213, original payment"), stores its hash with the approver, and execution proceeds only if the hash matches.
Why: Otherwise the approved action and the executed one can differ, and the approval proves nothing.
- 5Rate-limit and sandbox
Max 10 actions per minute per session; alerts on users near caps. The reports tool runs code in a fresh container with no credentials and no internet.
Why: Rate limits stop runaway loops like Saturday's. Sandboxes give generic power without ambient access.
- 6Audit decisions, not just transcripts
Append: user, task, proposed call, decision and reason, approver, token scope, result, and the ids of emails or documents in context.
Why: In the next incident, we can answer who, what, why it was allowed, and which content triggered it, in minutes.
| Risk tier | Examples | Control |
|---|---|---|
| Auto | Search availability, read a booking, draft a reply, internal note | Allowed; logged; rate-limited |
| Confirm | Change dates, cancel, refund ≤ 100 EUR, email a guest | Staff approve the exact arguments in a plain preview |
| Escalate | Refund above the cap, rate rules, other properties | Agent can't; it prepares a request for a manager |
| Forbid | Bulk guest export, delete records, arbitrary HTTP, change permissions | Not registered for any agent task |
Interactive simulation
Visual diagram
Permissions enforced around the model: per-task tools, a policy engine, credentials scoped to the user and action, sandboxed execution, approvals by risk tier, and an audit trail of every decision.
Analogy
- Hotel staff key cardsNo master key for new staffAgent permissionsLeast privilege
- Hotel staff key cardsCard opens their floors, during their shiftAgent permissionsScoped, short-lived credentials
- Hotel staff key cardsManager swipes for big refundsAgent permissionsRisk-based human confirmation
- Hotel staff key cardsLaundry room with no door to the officeAgent permissionsSandboxing
- Hotel staff key cardsEvery swipe is loggedAgent permissionsAudit logs
Where the analogy breaks: staff build judgement over months. An agent can be persuaded by a sentence it read a second ago. So its key cards should be even narrower and expire even faster.
Check your understanding
Practice ladder
Trace four proposed calls
Scenario Warm-upThe refund-assist manifest: getBooking auto; issueRefund confirm, max 100 EUR, original payment only, user's hotel only; exportGuests forbidden. Rate limit 10 actions/min. Tokens: on behalf of the user, 5 minutes.
A Lisbon receptionist (hotel LIS) is using the agent. Trace each proposed call: allowed, needs confirmation, denied (why), or escalated. 1. getBooking({ id: "LIS-48213" }) 2. issueRefund({ bookingId: "LIS-48213", amountEur: 60, method: "original_payment" }) 3. issueRefund({ bookingId: "AMS-10442", amountEur: 30, method: "original_payment" }) 4. exportGuests({ hotel: "LIS", month: "2026-10" })
Finish the cancellation manifest
Design PracticeComplete Lina's manifest for a cancel-booking task. Fill in the tier, scope and constraints for cancelBooking, and list at least two forbidden tools.
task: cancel-booking
tools:
getBooking: { tier: auto, scope: "booking:read hotel:{user.hotelId}" }
quoteCancellation: { tier: auto, scope: "booking:read rates:read hotel:{user.hotelId}" }
cancelBooking:
tier: ___
scope: ___
constraints: ___
forbidden: ___
credentials: { type: on_behalf_of, ttl_seconds: 300 }Permissions for the reports tool
Design PracticeHotel managers want the staff agent to answer questions like "average length of stay in October by room type". The quick plan: give the agent a runPython tool with a database connection string. Redesign it using this episode's ideas.
Let managers approve escalated refunds by chat?
Decision StretchRefunds above 100 EUR escalate to a hotel manager. Managers want to approve them by replying "yes" in the same staff-agent chat, instead of opening the finance tool. About 15 a day across the chain.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Your CTO asks: "The model is very good at following instructions now. Why can't we give the agent an admin key and tell it the rules?" Explain your answer.