Topic 67

Least Privilege for Agents

Least Privilege

An agent's blast radius is exactly what its tools can do with the credentials they hold. Not what the prompt asks for, not what the model intends, not what the eval set covers — what the credentials permit at the moment a call executes. Scoping that surface is the most effective security work available on an agent, and it is entirely conventional engineering: scopes, identity, ceilings, rate limits.

It gets skipped for one reason, and it is worth naming because the reason is honest. A single service account with broad permissions makes every tool work on the first afternoon, and nine scoped credentials with nine sets of permissions do not. The work is a day. The alternative is a system where any one tool going wrong reaches everything the other eight could reach.

What a tool may touch, whose records, how much, how often — and the actions no credential reaches at all
Capabilities that simply do not exist
No tool alters a seller's payout details, cancels a listing, deletes a customer account or issues anything in bulk, and no credential the agent holds reaches the endpoints that do — a capability the agent does not have cannot be talked into existence
Volume limits, at run, ticket and fleet scope
One refund per run, one per ticket, twelve an hour across the fleet with a page at eight — sized from roughly 25 tickets an hour, and the excess routes to a human instead of failing the ticket
The ceiling, in the dispatcher
An integer comparison on the amount in cents, evaluated at the moment the call executes: above $150 the answer is a person, not the model's judgement about whether $220 is reasonable in this case
Authorization by verified identity
The order must belong to the customer who authenticated to open the ticket, so no sentence in the context can make the check pass for somebody else — four lines that also catch a transposed digit like SU-88412
Nine tools, nine scoped credentials
Each with the narrowest permission that lets it work and its worst case written in a sentence beside the code — the union of those sentences is the security posture, and one shared account collapses them into a single row

Per-Tool Credentials

Each tool gets its own credential, with the narrowest permission that lets it do its job. Sundry runs nine, and the discipline shows up most clearly in the read tools: search_orders holds a credential that can look up orders and cannot issue a refund, so a compromise anywhere upstream of it — a bug in the orders service, a bad deploy, a steered model insisting the search tool should also settle the case — produces nothing that moves money.

ToolCredential permitsWorst case with that credential alone
search_orders, get_orderRead orders belonging to the ticket's customerOne customer's own order history is read again
track_parcelRead carrier status for a parcel on those ordersA delivery status is fetched needlessly
search_policyRead the policy indexPublished policy text is retrieved
start_return, offer_replacementBook one collection or one replacement per ticketA courier is booked wrongly — annoying, reversible, visible
issue_refundRefund up to $150 against one order on this ticket$150 leaves a seller balance, with a record naming the run
message_sellerSend one templated message to the seller of recordA seller receives a message they did not need
escalate_to_humanWrite a summary onto this ticket and move it to the human queueA person is handed a ticket that did not need one

Read the third column as the actual security posture of the system. Each row is a sentence a reviewer can argue with, and the union of the rows is the worst a fully compromised run can do — which is the blast-radius statement Topic 64 asked for, written out per capability. With one shared service account the third column collapses into a single row that reads "everything in the other rows", and nobody can tell you what a wrong track_parcel is worth any more.

Acting as the Customer, Not as an Administrator

The most valuable single line in this topic is about who a read is performed as. A ticket has a verified customer — they authenticated to open it — and every read the agent performs on that ticket should be authorized against that identity, not against an argument the model supplied. The agent is not an administrator with a directory of 900,000 customers behind it; it is that one customer's session with a language model attached.

Authorization by identity: the ticket decides what is readable, not the arguments
def authorize(spec, args, run):
    ctx = run.ticket                          # customer verified at ticket creation

    if spec.reads_orders:
        if not orders.belongs_to(args["order_id"], ctx.customer_id):
            return DENY, "order is not this customer's"

    if spec.name == "issue_refund":
        if args["amount_cents"] > 15_000:      # the $150 ceiling, in cents
            return NEEDS_HUMAN, "above ceiling"
        if run.refunds_issued >= 1 or ctx.refunds_issued >= 1:
            return DENY, "one refund per run and per ticket"
        if fleet.refunds_last_hour() >= 12:
            return NEEDS_HUMAN, "hourly refund cap reached"

    return ALLOW, None

In words: before any order is read, the dispatcher checks that the order actually belongs to the customer on this ticket, and refuses otherwise. No sentence anywhere in the context can make that check pass for somebody else's order, because the customer id came from authentication rather than from the conversation. The same check does a second job for free — it catches Chapter 8's grounding failures, where the model produced SU-88412, a well-formed id one digit from the right one and belonging to a different buyer. One control, two failure classes, and it is four lines.

This is where "the model can be induced to supply a different argument" stops mattering. Authorization by argument asks the wrong question — is this order id allowed — and the answer depends on text. Authorization by identity asks whether this customer may see this record, which is a fact about the world that the conversation cannot reach. Any read tool your agent has should be scoped this way before it goes anywhere near production.

Ceilings in Code

The $150 ceiling has been in this book since Chapter 1 and has moved exactly once — out of the prompt and into the dispatcher, in Chapter 5, the day the code started rejecting those calls. It belongs in the same function as authorization for one reason: the check needs the amount, the ticket and the customer at the same moment, and that moment is when the call executes. The model's own judgement about whether $220 is reasonable in this case is not part of the computation. A ceiling in the prompt is a suggestion one convincing paragraph away from a full refund; a ceiling as an integer comparison on amount_cents has no paragraph-shaped input at all.

Rate and Volume Limits

A ceiling bounds one action. Volume limits bound a bad afternoon. Sundry runs three, at three different scopes: one refund per run, one refund per ticket, and twelve refunds per hour across the whole fleet with a page at eight. The first two stop a single steered or looping run from repeating an action — the double refund of Chapter 3 was a retry rather than an attack, and the same limit catches both. The third is the one that turns a systematic problem into a bounded incident.

Pick the fleet number from the traffic rather than from instinct. Sundry handles about 4,200 tickets a week, roughly 25 an hour averaged round the clock, and refunds land on a small share of them; twelve an hour is comfortably above any legitimate peak and far below what an unbounded compromise would produce in the two hours before somebody looked. When the cap is reached the queue does not stop — refunds route to a human instead — which is the difference between a limit that protects the business and one that takes the support queue down at four in the afternoon.

Human-Only Actions

Some actions are not on the dial at all. At Sundry the agent may never refund above the ceiling, alter a seller's payout or bank details, cancel or modify a seller's listing, delete or merge a customer account, or issue anything in bulk. The enforcement is not a rule in the prompt saying it must not; it is that no tool in the surface does those things and no credential the agent holds can reach the endpoints that do — so when a reviewer approves a refund above the ceiling, that person issues it in the finance system on their own credentials, and the agent's run records the outcome without ever having been able to perform it. A capability the agent does not have cannot be talked into existence, which makes this the cheapest control in the chapter and the one most often replaced with a sentence.

Reviewing the Surface

Chapter 3 established that the tool list is a design artefact and gets reviewed when it changes. This topic adds one column to that review: for each tool, what could this do at worst, answered in writing and kept next to the code. Sundry's document is two pages and the review takes twenty minutes, because most changes touch one row of it. The value is in the rows nobody would have written down otherwise — that message_seller is an outbound channel as much as a communication tool, and that get_order is a read tool that imports a stranger's prose.

Run the review whenever a tool is added, whenever a credential's scope widens, and whenever a new consumer picks up a shared server, because the second consumer inherits the surface without inheriting the reasoning behind it. A tool list that grew by four entries over a quarter with no review is a permission set nobody has read as a whole — and the whole is the only thing an attacker is interested in.

Common Mistakes
  • One service account behind all nine tools — every tool inherits the union of the permissions, and the worst case of a read-only lookup becomes the worst case of the refund tool.
  • Authorizing a read against the order id the model supplied — the model can be induced to supply a different one, and the same weakness lets a transposed digit like SU-88412 reach another customer's record.
  • Keeping the ceiling in the system prompt — it is followed most of the time, and the exceptions are exactly the runs where something in the context argued that this case was pre-approved.
  • Shipping without volume limits — a single steered or looping run issues refund after refund at the ceiling, and the first signal is an accounting report the following morning.
Best Practices
  • Scope a credential per tool with the minimum permission that lets it work, and write the worst case of each one in a sentence next to the code.
  • Authorize every read and write against the ticket's verified identity rather than against arguments the model produced.
  • Enforce the ceiling and every volume limit in the dispatcher, at run, ticket and fleet scope, and route the excess to a human instead of failing the ticket.
  • Keep a written capability review, update it whenever the tool list or a credential scope changes, and re-run it when a second consumer joins a shared server.
Comparable toolsIAM roles one scoped identity per componentOAuth scopes the same idea at an API boundaryScoped API keys per-tool credentials without an identity providerCyberSecurity Deep Dive the identity material in full

Knowledge Check

What does giving each tool its own scoped credential buy that a single shared service account does not?

  • Better tool selection, because the model sees which permissions each tool holds and picks the least privileged one
  • Lower cost per ticket, since scoped credentials let the provider skip the permission metadata on every call
  • A complete audit trail, which is impossible to produce when several tools authenticate as the same identity
  • A stateable worst case per tool, so a failure in a read path cannot reach the permissions of the write paths

Why is authorizing a read against the ticket's verified customer stronger than validating the order id the model supplied?

  • It stops the model from supplying arguments at all, since the dispatcher derives every parameter from the ticket record
  • The identity comes from authentication rather than from the conversation, so no text in context can widen what is readable
  • Validating the format of an order id is unreliable, because ids change shape whenever the orders team adds a prefix
  • Checking ownership avoids a round trip to the orders service, so it is both faster and less likely to time out

Sundry caps refunds at twelve per hour across the fleet, with a page at eight. What does that limit actually buy?

  • It controls model spend, since refunds are the most expensive path through the loop and the cap bounds the bill
  • It removes the need for idempotency keys, because a duplicate refund cannot get through the hourly counter twice
  • It turns a systematic compromise into a bounded incident, with the excess routed to humans rather than dropped
  • It prevents individual wrong refunds, because each call is checked against the pattern of the eleven before it

Sundry's agent may never alter a seller's payout details. How is that enforced?

  • No tool exposes the action and no credential the agent holds can reach it, so the capability is simply absent
  • A prohibition in the system prompt, restated at the top of every run so it stays close to the decision
  • An approval gate that routes every payout change to the finance team for a second signature before it commits
  • A monitor that watches the payout table and raises an alert whenever a change is attributed to an agent run

You got correct