Agentic Security / agent / authorization / tools
Excessive Agency: Tool Surface Versus Credential Scope
That narrowing the tool narrows the risk: name it read_emails, document it as read-only, validate its arguments, and the agent cannot send. The tool is a client; the authorisation lives in the mailbox. OWASP separates three independent causes — excessive functionality, excessive permissions, excessive autonomy — and teams almost always attack the first, which is the cheapest to fix and the least load-bearing. Removing a tool from the manifest removes nothing from the token, and the credential is what an attacker inherits.
A tool called summarise_inbox, documented as read-only, with a
schema that accepts nothing but a date range, can send mail. Whether it can
is decided by the mailbox, not by the tool — and the mailbox was never asked
about the tool's name.
OWASP — the Open Worldwide Application Security Project, which publishes the Top 10 lists most security programmes are built around — files this as LLM06:2025, Excessive Agency, in its Top 10 for Large Language Model Applications. Its definition is worth reading closely: the vulnerability "that enables damaging actions to be performed in response to unexpected, ambiguous or manipulated outputs from an LLM, regardless of what is causing the LLM to malfunction." A large language model (LLM) is the text-predicting component; an agent is that model wired to tools it can call. Excessive agency does not ask why the model went wrong. It asks what happens next.
OWASP names three independent root causes: excessive functionality, excessive permissions, and excessive autonomy. Almost every team attacks the first — trim the tool list, tighten the JSON Schema, write a docstring that says read-only — because it is the one that lives in code they own. It is also the one that decides the least. The tool is a client. The authorisation lives at the other end of the connection, in the permissions attached to the identity the tool authenticates with, and nothing you write in the tool changes it.
Below is that separation made mechanical. Twelve capabilities across four backend systems; four tools, each with a surface you choose and an identity you choose; four layers that could stop a request, only some of which actually can. The scenario is a successful indirect prompt injection — the model is doing what an attacker asked. The question the simulation answers is what that gets them. Start from the defaults and turn off the system prompt forbids destructive actions.
summarise_inboxproduct_lookupread_reporun_commandA capability is one verb against one backend. It is reachable when some offered tool can express it, no approval gate stops it, and the identity that tool authenticates with is granted it. The agent's actual job needs three: read the user's own inbox, look up a product, read the repository.
reachable and high-impact · reachable, read-only · stopped, with the layer that stopped it. Note that no tile is ever stopped by the model.
What is real and what is a model. The layer order is real and is
OWASP's: the tool surface decides what can be expressed, an approval gate
decides what a human sees, and the downstream system decides what the
credential is allowed to do. The grant sets attached to each identity are
the real ones, and they are pairs for a reason. GitHub's
repo scope grants "full access to public and private
repositories including read and write access to code" but not deletion;
deleting needs delete_repo, a separate scope on the same
classic-token page. Microsoft Graph's Mail.ReadWrite allows
an app "to create, read, update and delete mail in user mailboxes" and
then says, in the permissions reference itself, "Does not include
permission to send mail" — sending needs Mail.Send beside it.
Both identities here hold the pair, which is the point: the destructive
verb is one extra checkbox away from the one you needed, on the same
screen, at the same moment.
What is modelled is the record counts in the blast radius,
which scale off the tenant slider rather than off any real directory.
Turning the system prompt off changes nothing. Not one tile moves, and stopped by the model's instructions reads 0 at every setting in this simulation, because there is no setting at which it could read anything else. This is the seventh of OWASP's eight prevention strategies, the one it calls "complete mediation", restated as an instrument: "Implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed or not."
The default configuration is an ordinary one. Three tools, all documented as read-only helpers, none of them advertising a destructive function in its name. The hero number is 6: six of the seven high-impact actions are reachable. Ten of the twelve capabilities are reachable in total, and the agent's actual job needs three. Read the reachable tiles and note that every one of them names a credential, not a tool.
Two tiles are stopped, by two different layers, and the difference between
them is the lesson. host.exec is stopped at the surface:
no tool implements it, because run_command is not offered.
mail.read.all is stopped at the downstream: the vendor
extension can express it perfectly well, no approval gate is running, and
the mailbox simply refuses, because Mail.ReadWrite and
Mail.Send are delegated scopes over one user's mail. The second layer is the only one in this simulation
with a perfect record, and it is the only one that is not in your codebase.
Three causes, and which one you actually fixed
Work the three OWASP causes one at a time from the defaults and watch which number moves.
Excessive functionality is the tool surface. Set
product_lookup to lookup_product(product_id). The hero
drops from 6 to 4 and stopped by the tool surface climbs from 1 to 4.
Real progress — and read what the db.update tile now says. It is
stopped because the extension cannot express it, not because the database
would refuse. The role in DATABASE_URL still owns the schema.
You have removed the sentence, not the permission, and the next tool anyone
adds that takes a string restores it.
Excessive permissions is the credential. Put the surface back to
query(sql) and instead set the identity to role
app_ro_products. The hero drops from 6 to 4 again — the same two
actions — but now the tiles read stopped at the downstream, and
stopped by the downstream permission check goes from 1 to 4. The
difference between these two configurations is invisible in the hero number
and total in every other respect: one of them survives someone adding an
open-ended tool next quarter, and the other does not.
Excessive autonomy is the approval gate, and it is the one with a
shape people do not expect. Set approval to prompt before any high-impact
action from the defaults. The hero drops from 6 to 4, and the two it
catches are repo.write and repo.delete. It does not
catch mail.send, mail.delete,
db.update or db.delete — and the reason is in the
tile text. read_repo exposes push and delete as their own
functions, so the prompt can name the action. The vendor mailbox extension
exposes one function with an action parameter, and the SQL runner exposes one
function that takes a string, so the client gets one prompt per call and has
no idea which of the four capabilities behind it that call will exercise.
Now set summarise_inbox to read_inbox, send_email and
delete_mail, as separate functions with approval still on high-impact.
The hero drops from 4 to 2. Splitting the extension removed no
capability at all — check it by setting approval back to no approval,
where the hero returns to 6 — but it gave the approval gate something to
name. That is the mechanism behind OWASP's advice to put
the approval routine "within the extension that implements the 'post'
operation", and it is why granularity of the tool surface and strength of the
approval control are the same variable.
The tool that inherits everything
Return to the defaults and tick run_command. The hero goes to
7 of 7, and reachable capabilities go from 10 to 11 of 12 — every
tile but mail.read.all, which the mailbox still refuses. Now do
the thing that should help: untick product_lookup and
read_repo, leaving only the mail tool and the shell. The hero
stays at 7 and the total stays at 11.
The credentials log line says why. A shell running as your own
account does not have the permissions it was granted; it has the permissions
of every credential readable from your home directory. The
DATABASE_URL is in .env whether or not
product_lookup is in the manifest. The git credential helper
answers to anything that shells out to git. The OAuth refresh
token is in the client's own config file. Removing a tool removes a function
call. It does not remove a file.
This is the case that breaks the mental model of a tool manifest as a permission boundary, and it is not exotic — it is the default posture of every coding agent that runs on a developer laptop with a general shell tool. The manifest is a menu. The filesystem is the actual grant.
Now switch run_command's identity to a dedicated
unprivileged user; secrets injected per call by a broker, with every tool
switched back on. The hero goes from 7 to 7 — no change at all —
because every other tool is still over-permissioned and the shell was never
the only route. Then set product_lookup to
app_ro_products, read_repo to the fine-grained
token and summarise_inbox to Mail.Read. The hero
becomes 1 — just host.exec, which is what a shell is for
— and reachable capabilities fall to 4 of 12: the three the agent
needs, plus running a command. Eight of the twelve are now stopped
downstream, which is the only place a stop is durable.
Notice what that took: four changes, all of them to credentials, none of them to a tool's name, description or schema. The credential broker pattern is the one that generalises, because it is the only arrangement in which the answer to "what can this agent do" is not "whatever is in the environment it inherited". It also makes revocation mean something — revoking a token is only a control if there is a single place the token came from.
Acting as the app instead of as the user
Set summarise_inbox's identity to app-wide service principal,
Mail.ReadWrite.All and watch the blast radius readout rather than the
hero. The hero moves from 6 to 6 — no high-impact action was added, because
the delegated pair already carried send and delete. The blast radius
goes from 1 mailbox to 4,200 mailboxes.
This is OWASP's fifth example of risk, stated as a scale: "an extension to read the current user's document store connects to the document repository with a privileged account that has access to files belonging to all users." It is the failure mode that does not show up in a capability count at all, which is exactly why capability counts are not sufficient. The verb set is identical. The resource set is the entire tenant.
It happens for a mundane reason. A tool that runs server-side has to authenticate as something, and propagating the calling user's identity through an agent framework into a downstream call is real work — enough work that two of the most-viewed questions on this topic are people discovering that their tools run with no caller context at all, and asking how to find out which user a tool call is even for. The service principal is what you get when nobody does that work, and it is not a decision anyone records having made. The alternative is the fifth item on OWASP's list, "execute extensions in user's context", which in practice means an OAuth authorization code flow per user with the minimum scope, and a token cache keyed by user rather than by process.
Drag the tenant slider while the service principal is selected. The hero number does not move at all, at any value. Every incident-severity conversation you will ever have is about the number that is moving.
Where the cheapest fix runs out
The last two log lines search every single change available from the current state — stop offering a tool, narrow a surface, re-issue a credential, turn on approval — and report both the best one and how the effective ones break down by kind. At the defaults, all 10 available changes remove at least one high-impact action: 3 credential changes, 3 surface changes, 3 manifest changes and turning on approval. Every layer is working, so every layer looks equally good. This is the state in which tool-layer advice sounds correct.
Now turn on run_command with your own account and set
summarise_inbox to the app-wide service principal. The
tally collapses: 12 changes available, 4 of them effective, and
3 of those 4 are credential changes. Changes to a tool's surface: 0.
Turning on approval: 0. Narrowing the SQL runner is now worth exactly
nothing, because the shell expresses the same capability with the same
credential; the only manifest change that helps is removing the shell itself,
and that costs the agent a capability it needs.
That collapse is the point of the whole simulation. The tool layer's leverage is not fixed — it is a function of whether an open-ended tool with inherited credentials is present. Add one and every hour spent on schemas, docstrings and manifest hygiene stops changing the number, in a way that no review of the tool code would reveal, because the tool code is not where the change happened.
It is also the honest boundary of this whole framing. Excessive agency is a blast-radius control, not a prevention: it decides how bad a successful injection is, and does nothing about whether one succeeds. The three legs that decide whether a path exists at all are the lethal trifecta, and the reason you must assume the injection lands is measured in the defence-limits lesson. If you have a residual attack success rate of a few percent and an agent that runs thousands of times a day, the blast radius is the number that decides your year.
OWASP is explicit that two of the most commonly deployed measures are not in the prevention list at all. Logging and rate limiting "will not prevent Excessive Agency, but can limit the level of damage caused". That is a precise claim and worth holding onto: a rate limit turns a single catastrophic action into a slower stream of them, buying the time in which monitoring might work. It is a real control. It is not a boundary.
Checking this on a system you actually run
Do not audit the tool manifest. Audit the credentials, and do it from the
downstream side, because that is where the answer lives. For every tool,
write down the exact principal it authenticates as, then go to that system
and ask it what the principal can do — not what you intended it to do. In
PostgreSQL that is \dp on the tables the agent's role can see,
or SELECT * FROM information_schema.role_table_grants WHERE
grantee = 'agent_role'; the row you are looking for is any
privilege_type other than SELECT. For an OAuth
identity provider it is the granted scope list on the live token, not the
scope string in your source: decode the access token and read the
scp or scope claim. For a cloud role it is the
policy simulator, run against the specific destructive actions rather than
against the role as a whole.
For every one of those credentials, ask a second question: is it a file on a
machine the agent can read? If it is, then every open-ended tool the agent
has — a shell, an HTTP fetcher, a Python interpreter, a "run this script"
step in a workflow — holds it too, and your tool-by-tool inventory is
counting the wrong things. Grep the agent's runtime environment for the
obvious ones: env | grep -iE 'token|key|secret|password|_url' in
the container the agent actually runs in, plus ~/.netrc,
~/.aws/credentials, ~/.config/gh/hosts.yml and the
git credential helper's store. Every hit is a grant that no tool manifest
mentions.
Then instrument the downstream side rather than the agent side. Turn on
per-principal audit logging at each backend and alert on the verb, not the
volume: any UPDATE, DELETE or INSERT
by a role you believe to be read-only; any SendMail by an
identity you believe only reads; any push by a token you believe is
contents:read. These alerts should fire zero times a year, which
makes them the cheapest high-signal detection in this whole area. If one of
them is noisy, you have found an excessive permission without needing an
incident to find it for you.
Finally, price the approval gate before you rely on it. Count how many prompts your current policy would generate in a normal week and what fraction of them would be high-impact. If the answer is hundreds of prompts with a handful of interesting ones, the gate is a control on paper whose real catch rate is something else entirely — which is what approval fatigue measures, and why the granularity of your tool surface matters more than the strictness of your policy. Where you place the enforcement point at all — in the prompt, in the client, in the process, in the kernel — is a separate question with very different guarantees at each layer.