DeepConcepts

Agentic Security / agent / authorization / tools

Excessive Agency: Tool Surface Versus Credential Scope

The misconception

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.

13 min

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.

tool
in the manifest
what the extension implements
the identity it authenticates as
summarise_inbox
product_lookup
read_repo
run_command
open-ended by construction — one string argument

A 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.

high-impact actions reachable, of 7
capabilities reachable, of 12 — the agent needs 3
stopped by the model's instructions
stopped by the tool surface
stopped by an approval gate
stopped by the downstream permission check
blast radius
Every capability, and the layer that decided it

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.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.