Excessive Agency and Least Privilege for Agents
Separate the three dimensions of agency, fix the confused deputy problem with identity propagation, and place approval at the action tier instead of the session.
Learning objectives
- Separate functionality, permissions, and autonomy as independent risks
- Recognize the confused deputy pattern in an agent architecture
- Propagate caller identity through tool calls instead of using service accounts
- Place human approval at the action tier rather than the session
ToolDix original visual
Frame
Name the outcome and constraints.
Build
Try one bounded workflow.
Review
Keep evidence, revise, and share.
Agency is three risks wearing one coat
"Give the agent less power" is agreed upon by everyone and implemented by almost no one, because it is not one decision. Excessive agency has three independent dimensions, and a team can tighten one while leaving the other two wide open — which is the usual outcome.
Functionality: how many tools it has. Agents accumulate capability the way applications accumulate dependencies. A team needs to send an email, imports a mail SDK, and wraps it as a tool. The wrapper exposes send, but the underlying client the tool holds can also list mailboxes, read messages, and delete folders. The tool schema is not the boundary; the library's reach is. When someone later adds a code path that passes a method name through, the extra capability is already sitting there.
Permissions: what each tool may touch. The tool may be narrow while the credential behind it is enormous. A read-only reporting tool that authenticates with the platform service account can read every table in the warehouse. Nothing in the tool definition reveals this. It surfaces during an incident.
Autonomy: how far it runs unchecked. Twenty chained tool calls with no confirmation is a categorically different risk from one call per approval, even with identical tools and identical credentials. Autonomy is the dimension teams argue about, because it is the one users feel. It is also the one that matters least if the first two are tight, and the one that cannot save you if they are loose.
The practical value of separating these is diagnostic. When someone says an agent is "too powerful," ask which of the three they mean. The answers lead to different fixes: trimming a tool schema, re-scoping a credential, or inserting an approval gate.
The confused deputy
The confused deputy is a decades-old access control problem, and agent architectures reproduce it almost by default.
A deputy is a program that acts on behalf of others while holding its own authority. It becomes confused when it uses its own authority to do something the requester was not entitled to do. Your agent is a deputy. It holds a credential, it takes instructions, and unless you built it otherwise, its credential is not the requester's.
The provisioning path that creates this is entirely reasonable. You build an agent. It needs database access. Creating one service account is simpler than plumbing per-user credentials through an async tool-calling loop. The service account needs to work for all users, so it gets broad read access. Ship.
Now the only thing standing between a low-privilege user and any record in that database is the model's judgment about what to query. The model is not an authorization system. It has no reliable notion of who is asking, it can be persuaded by an injected instruction, and it will sometimes simply make a mistake in a long chain of reasoning.
The fix is identity propagation. The caller's identity travels with the request and is used at the point of access:
async def handle_turn(message: str, principal: Principal):
# The principal is bound into the tool implementations for this turn.
# There is no code path that constructs a tool without one.
tools = build_tools(principal)
return await agent.run(message, tools=tools)
def build_tools(principal: Principal) -> list[Tool]:
def account_lookup(user_id: str) -> dict:
# Authorization is a deterministic check outside the model.
# The model can ask for anything; this decides what it gets.
if not can_read_account(principal, user_id):
raise PermissionDenied(f"{principal.id} may not read account {user_id}")
return accounts.fetch(user_id, fields=SUMMARY_FIELDS)
return [Tool(name="account_lookup", fn=account_lookup, schema=ACCOUNT_LOOKUP_SCHEMA)]
Two things make this work, and both are structural rather than behavioral. build_tools requires a principal, so a tool cannot be constructed without one. And the authorization check runs in ordinary code that the model cannot argue with.
There is a real cost. Per-user credentials complicate connection pooling, background jobs, and any workflow that must continue after a user's session ends. Those are genuine engineering problems with known solutions — token exchange, scoped delegation, explicit consent records for background work. They are worth solving, because the alternative is that your access control lives in a system prompt.
One caveat worth stating: propagating identity does not help if every user has broad permissions in the underlying system. It moves authorization to the right layer; it does not fix over-permissioned users. Both need attention.
Approval at the action tier
The second structural control is where you place human approval, and most products place it in the worst possible spot.
"Allow this agent to run for this session" is a decision made before anyone knows what will be attempted. It is the security equivalent of signing a blank cheque, and users learn to click it immediately because it appears when they are trying to start work. Meanwhile, per-call approval for everything trains users to approve reflexively, which is the same outcome with more friction.
Sort actions into tiers by consequence, and let the tier determine the gate.
Tier 0 — read, reversible, internal. Reading a file, querying a dashboard, searching documentation. Run automatically. Log everything. Asking permission here produces alert fatigue and buys nothing.
Tier 1 — write, reversible, internal. Drafting a document, opening a pull request, creating a draft ticket. Run automatically, but guarantee an undo path and make the change visible before it has any effect. A pull request is the ideal shape: the work is done, nothing has happened yet.
Tier 2 — outbound or irreversible. Sending an email, making a payment, deleting data, deploying, granting access, posting publicly. Every one requires explicit human approval of that specific action.
The tier boundary is not "how likely is the model to be wrong." It is "if it is wrong, can we undo it." A model that is right ninety-nine percent of the time is fine for tier 0 and unacceptable for tier 2 at volume.
Two implementation details decide whether tier 2 approval actually works:
Show the action, not a description of it. The approval dialog must display the concrete call — the recipient, the amount, the exact rows matched by the delete — rendered by your code from the actual arguments. If it displays a summary the model wrote, the model is describing its own action to its reviewer, and a confused or manipulated model will describe it favorably.
Bind approval to the exact arguments. Approve transfer(to="acct_99", amount=500), not "the transfer." If any argument changes between approval and execution, the approval is void and must be re-requested.
Worked example: trimming a deployment agent
An agent that helps engineers ship. It reads the repository, runs tests, opens pull requests, and can trigger deployments. Walk the three dimensions.
Functionality audit. The agent has six tools. run_shell is one of them, added early for convenience. run_shell subsumes every other tool and grants unbounded capability — a single tool that makes the other five decorative from a security standpoint. It is replaced with three specific tools: run_tests, run_linter, and read_file, each invoking a binary directly with an argument array, never a shell string. Capability drops sharply while the agent's actual usefulness is unchanged, because nobody was using run_shell for anything the three specific tools do not cover.
Permission audit. The deployment tool authenticates with a CI service account that can deploy any service to any environment. It is re-scoped: the agent gets a credential that can deploy only the services the requesting engineer already owns, and only to staging. Production deployment is removed from the agent entirely and stays in the existing human-operated pipeline, because no one could articulate what the agent added there.
Autonomy audit. Tiers are assigned. Reading files and running tests are tier 0 and run freely. Opening a pull request is tier 1: it happens automatically, and the pull request itself is the review surface. Staging deployment is tier 2 and shows the engineer the exact service, commit digest, and target environment before executing.
The result is worth stating plainly, because it is the general shape of this work. The agent does the same job it did before. The demo looks identical. What changed is that the worst outcome of a fully successful prompt injection went from "arbitrary code execution with CI credentials in production" to "an unwanted staging deployment of a service this engineer already owns, which someone approved on a screen showing exactly what it was."
That is what least privilege buys. Not prevention — bounded consequences.
Common mistake
The most common mistake is auditing autonomy while ignoring functionality and permissions. A team spends its security review debating how many steps the agent may take unattended, ships with a run_shell tool and a shared admin credential, and considers the risk managed. The number of steps is close to irrelevant when step one can do anything.
A second mistake is treating the tool schema as the permission boundary. A schema constrains what the model can ask for. It says nothing about what the credential behind the tool can do when a bug, an injected instruction, or an unusual argument reaches the implementation. The boundary is the credential and the authorization check, and both live below the schema.
The correcting exercise takes twenty minutes: list every tool your agent has, and next to each one write the identity it acts as and the full set of operations that identity can perform — not the operations the tool exposes. The gap between those two columns is your excessive agency, written down.
Sources and license context
These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.
- LLM06:2025 Excessive Agency (opens genai.owasp.org in a new tab)External · genai.owasp.org (OWASP project terms apply)
- A Practical Guide to Building Agents (opens cdn.openai.com in a new tab)External · cdn.openai.com (OpenAI terms apply)
Keep going
Read these next on ToolDix.
Original lessons that build on what you just read.