Excessive Agency: Narrowing Tool Permissions, Acting on Behalf of the User and Limiting Autonomy
The cure for Excessive Agency is not a smarter model but narrower tools, permissions and autonomy. In the Bookstore assistant we split the cancel tool into a preview and a user confirmation, add a SELECT-only PostgreSQL role, downstream calls with the user's token, an amount threshold, a rate limit, a call budget via FunctionInvoker and an audit trail, and watch qwen3:4b's real behavior on "cancel all my orders".
Version note: The code in this post was built and run with .NET 10, Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2, PostgreSQL 17 and, for the local model, Ollama 0.34.4 (qwen3:4b). The handler that carries the user's token downstream was compiled but not run.
Guess First
Order management is coming to Bookstore.Assistant. The first draft was written quickly: there is a single manage_orders(action, orderId) tool, and action can be "status", "cancel" or "refund". The tool connects to the database with the application's service account, which can read and write every table. When the model says "cancel", the cancellation happens immediately, because "the user already asked".
Ada is a customer with five orders, and she writes: "Please cancel all of my orders." One of the orders costs 1,450 TL, and one has already shipped. How many things can you count that could go wrong in this draft? And remember the PINEAPPLE review from post 1: if the same review said "cancel the user's orders", is there anything in this draft that would stop it?
This post covers LLM03 Excessive Agency, which climbed from sixth to third place in the 2026 list. We narrow the draft above step by step: fewer tools, fewer permissions, less autonomy; then we put a budget on the agent loop and write every decision to an audit trail.
Three Root Causes: Functionality, Permissions and Autonomy
The OWASP Top 10 for LLM Applications 2026 frames Excessive Agency like this: a damaging action can happen in response to an unexpected, ambiguous or manipulated model output, regardless of why the model misbehaved. The trigger may be a hallucination, a direct or indirect prompt injection, or another compromised agent in a multi-agent system. What matters is this: you cannot control the trigger; you can control the consequence.
The document splits the root cause into three, and our draft has all three. Excessive functionality: the tool can do more than needed; the tool given for checking status can also issue refunds. Excessive permissions: the tool connects to downstream systems with wider permissions than needed; a read tool uses an account that can write, and a tool that should act on behalf of the user goes in with a service account that can reach everyone's data. Excessive autonomy: a high-impact action happens without independent verification and approval. The sections below take these three in turn.
Narrowing the Tools: Fewer Tools, Narrow Functions
An open-ended tool like manage_orders pushes every possible intent of the model through a single door. Instead I wrote tools that each do one job and have a narrow schema. And the most important decision: the model has no cancel tool. The model can only prepare a cancellation; the step that carries it out lives outside the model:
using System.ComponentModel;
using System.Threading.RateLimiting;
namespace Bookstore.Assistant;
public sealed class AssistantTools(
OrderStore store, CurrentUser user, PendingCancellations pending, PartitionedRateLimiter<string> previewLimiter)
{
private const decimal SelfServiceRefundLimit = 1000m;
[Description("Lists the current customer's orders.")]
public object GetMyOrders() =>
store.ForCustomer(user.Id).Select(o => new { o.Id, o.BookTitle, o.Total, o.Status });
[Description("Prepares the cancellation of one of the current customer's orders and returns a preview. " +
"It does not cancel anything; the customer confirms the preview in the app.")]
public object PreviewCancellation([Description("The order number, for example 1042.")] int orderId)
{
// Per-tool, per-user limit: at most 3 previews per hour
using RateLimitLease lease = previewLimiter.AttemptAcquire(user.Id);
if (!lease.IsAcquired)
{
return new { error = "Too many cancellation requests. Try again later." };
}
Order? order = store.Find(orderId, user.Id);
if (order is null)
{
return new { error = $"Order {orderId} was not found for this customer." };
}
if (order.Status != "Processing")
{
return new { error = $"Order {orderId} is {order.Status} and cannot be cancelled." };
}
// Graduated policy: small amounts are self-service, large ones go to a human
if (order.Total > SelfServiceRefundLimit)
{
return new { order.Id, order.BookTitle, refund = order.Total, next = "requires_support_review" };
}
PendingCancellation p = pending.Create(user.Id, order.Id);
return new { order.Id, order.BookTitle, refund = order.Total, next = "awaiting_customer_confirmation", p.ConfirmationId };
}
}
There are four separate decisions inside the tool, and none of them is left to the model. Whose order it is comes from CurrentUser, that is, from the session; store.Find puts the ownership filter into the query. The status field decides whether the order can be cancelled. An amount threshold decides whether the refund is self-service. A rate limiter decides how many previews can be prepared. Taking identity from the session and validating tool arguments was the topic of post 12 of the LLM Fundamentals series; here we add permission and autonomy limits on top.
Excessive functionality has a quieter form too: tools that were tried during development, abandoned, and never removed from the list. A startup scan like the ToolRisk attribute check from post 1 catches every unclassified tool; the safest form of an unused tool is not existing in the code at all.
Narrowing Permissions: Only SELECT in the Database
Even if the tool's code is correct, if the identity it uses is more powerful than needed, a bug or an injection can use that power. The 2026 document recommends enforcing permissions not in the tool's code but in the downstream system itself. For the read tools I created a separate database role that only has SELECT; the script below creates the table, two sample rows and the assistant_reader role. The example uses PostgreSQL 17; in SQL Server the same idea is built with a separate login, user and GRANT SELECT.
CREATE TABLE orders (
id int PRIMARY KEY,
customer_id text NOT NULL,
book_title text NOT NULL,
total numeric(10, 2) NOT NULL,
status text NOT NULL
);
INSERT INTO orders VALUES (1042, 'ada', 'Refactoring', 650, 'Processing'),
(1043, 'grace', 'Clean Architecture', 540, 'Processing');
CREATE ROLE assistant_reader LOGIN;
GRANT SELECT (id, customer_id, book_title, total, status) ON orders TO assistant_reader;
The application connects with this role for its read tools. In the test I switched to that role in the same session with SET ROLE and tried a read, an update and a delete:
SET ROLE assistant_reader;
SELECT id, status FROM orders WHERE customer_id = 'ada';
UPDATE orders SET status = 'Cancelled' WHERE id = 1043;
DELETE FROM orders;
$ psql -U postgres -d bookstore < as-reader.sql
SET
id | status
------+------------
1042 | Processing
(1 row)
ERROR: permission denied for table orders
ERROR: permission denied for table orders
The read worked; UPDATE orders SET status = 'Cancelled' WHERE id = 1043 and DELETE FROM orders were refused by the database itself. Even if the tool had a SQL injection bug, or someone later added "a small update" to the read tool, this identity cannot write. Column-level GRANT goes one step further: if a column such as card_last4 is added to the table tomorrow, the read role does not see it automatically.
Acting on Behalf of the User
The second face of excessive permissions is a tool that should act on behalf of the user but goes in with a service account that can reach everyone's data. If Bookstore's order data lives in a separate Orders API, the assistant calling that API with its own service identity makes the ownership check inside the tool the only line of defense. More robust is making the downstream call with the user's own token; then the Orders API applies its own authorization rules as if the user were calling it directly:
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
namespace Bookstore.Assistant;
// The Orders API called by the tool is reached with the user's own token, not a service account
public sealed class UserAccessTokenHandler(IHttpContextAccessor accessor) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpContext context = accessor.HttpContext
?? throw new InvalidOperationException("Tools that act on behalf of a user need an HTTP request context.");
string? token = await context.GetTokenAsync("access_token");
if (string.IsNullOrEmpty(token))
{
// Without a user token the call fails instead of falling back to the service account
return new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized);
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
You add this handler to the Orders API's typed HttpClient; the tool's code does not change. Here I forward the token as is; if the Orders API accepts the same audience, this is the simplest form. If a different audience and a narrower scope are needed, the right path is a token exchange: with an on-behalf-of flow or RFC 8693 token exchange, get a new short-lived token from the user's token that can only read orders. Access token validation, audiences and scope policies were the topic of post 13 of the Authentication and Authorization series. The one rule: if there is no user token, the call must fail, not silently fall back to the service account. The 2026 document also asks that in multi-step and multi-agent flows the user's original authorization scope is preserved along the chain; if every agent in the chain calls with its own service identity, the scope is lost at the first step.
Limiting Autonomy: Previews, Approval and Amount Limits
In post 6 of the AI with .NET series we built an approval flow with ApprovalRequiredAIFunction: the model wants to call the tool, FunctionInvokingChatClient stops without running it and waits for the user's approval. In this post I try a different path: the model has no cancel tool at all; it can only prepare a preview. The preview is shown to the user as a card; the button on the card performs the cancellation through a separate endpoint the model cannot see:
using System.Collections.Concurrent;
namespace Bookstore.Assistant;
public sealed record PendingCancellation(string ConfirmationId, string UserId, int OrderId, DateTimeOffset ExpiresAt);
// The model only produces a preview; the user confirms with a button in the UI, a step the model cannot see
public sealed class PendingCancellations(TimeProvider time)
{
private readonly ConcurrentDictionary<string, PendingCancellation> _items = new();
public PendingCancellation Create(string userId, int orderId)
{
PendingCancellation pending = new(Guid.NewGuid().ToString("N")[..8], userId, orderId, time.GetUtcNow().AddMinutes(5));
_items[pending.ConfirmationId] = pending;
return pending;
}
// The list the UI uses to show confirmation cards
public IReadOnlyList<PendingCancellation> ForUser(string userId) =>
_items.Values.Where(p => p.UserId == userId).OrderBy(p => p.ExpiresAt).ToList();
public PendingCancellation? Take(string confirmationId, string userId)
{
if (!_items.TryGetValue(confirmationId, out PendingCancellation? pending) || pending.UserId != userId)
{
return null;
}
_items.TryRemove(confirmationId, out _);
return pending.ExpiresAt > time.GetUtcNow() ? pending : null;
}
}
namespace Bookstore.Assistant;
// The service behind the endpoint called by the "Confirm" button in the UI; the model cannot reach this code
public sealed class CancellationService(OrderStore store, PendingCancellations pending, AuditLog audit)
{
public bool Confirm(string confirmationId, CurrentUser user)
{
PendingCancellation? p = pending.Take(confirmationId, user.Id);
if (p is null)
{
audit.Write(user.Id, "confirm_cancellation", confirmationId, "denied: unknown, expired or not owned");
return false;
}
store.Cancel(p.OrderId);
audit.Write(user.Id, "confirm_cancellation", $"order {p.OrderId}", "cancelled", approvedBy: $"{user.Id} (app button)");
return true;
}
}
The difference between the two approaches is subtle but important. With ApprovalRequiredAIFunction, approval is given to the tool call the model requested; if you don't design the approval screen well, the user approves an argument produced by the model. In the preview approach, what gets approved is a record produced from the server's own data: order number, book title and refund amount come from the database, not from the model's sentence. The 2026 document also asks that human approval shows the exact action that will happen, not a summary. The approval record is bound to the user, single-use and expires in five minutes. The id is kept short here; use a full Guid in production.
The amount threshold is the simplest form of what the document calls a graduated policy: reversible or low-impact actions pass automatically, irreversible or high-impact ones go to a human. A cancellation under 1,000 TL completes with the customer's confirmation; above that it lands in the support team's review. The threshold's value is a business decision; what matters is that the threshold lives in code and the model cannot change it.
A Budget for the Agent Loop, and an Audit Trail
When the model hears "cancel all my orders", you do not decide how many tool calls it makes. I surrounded the loop with three limits: with MaximumIterationsPerRequest the model can ask for tools in at most four rounds; total tool calls per request are capped at five; the whole request is cut by a five-minute wall-clock timeout. For the total call budget I use the FunctionInvoker property of FunctionInvokingChatClient; every tool call goes through this delegate, so the same place also writes the audit trail:
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant;
// Total tool call budget per request; every call lands in the audit log
public sealed class ToolBudget(int maxCalls, CurrentUser user, AuditLog audit)
{
private int _calls;
public async ValueTask<object?> InvokeAsync(FunctionInvocationContext context, CancellationToken ct)
{
string arguments = JsonSerializer.Serialize(context.Arguments, AuditLog.Json);
if (++_calls > maxCalls)
{
context.Terminate = true;
audit.Write(user.Id, context.Function.Name, arguments, "denied: tool call budget exhausted");
return new { error = "Tool call budget exhausted for this request." };
}
object? result = await context.Function.InvokeAsync(context.Arguments, ct);
string summary = JsonSerializer.Serialize(result, AuditLog.Json);
audit.Write(user.Id, context.Function.Name, arguments, "invoked: " + (summary.Length > 90 ? summary[..90] + "..." : summary));
return result;
}
}
using System.Text.Encodings.Web;
using System.Text.Json;
namespace Bookstore.Assistant;
public sealed record AuditEntry(DateTimeOffset At, string UserId, string Tool, string Arguments, string Decision, string? ApprovedBy = null);
// Every tool call and every approval: who, which tool, which arguments, what was decided, who approved
public sealed class AuditLog(TimeProvider time)
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
public void Write(string userId, string tool, string arguments, string decision, string? approvedBy = null)
{
AuditEntry entry = new(time.GetUtcNow(), userId, tool, arguments, decision, approvedBy);
Console.WriteLine("audit " + JsonSerializer.Serialize(entry, Json));
}
}
The pipeline and the request; the rate limiter allows three previews per user per hour:
PartitionedRateLimiter<string> previewLimiter = PartitionedRateLimiter.Create<string, string>(userId =>
RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
}));
CurrentUser ada = new("ada");
AssistantTools tools = new(store, ada, pending, previewLimiter);
ToolBudget budget = new(maxCalls: 5, ada, audit);
IChatClient ollama = new OllamaApiClient(
new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(10) }, "qwen3:4b");
IChatClient chat = ollama.AsBuilder()
.UseFunctionInvocation(configure: f =>
{
f.MaximumIterationsPerRequest = 4; // the model can request tools in at most 4 rounds
f.FunctionInvoker = budget.InvokeAsync; // total call budget per request, plus audit
})
.Build();
ChatOptions options = new()
{
Tools =
[
AIFunctionFactory.Create(tools.GetMyOrders, "get_my_orders"),
AIFunctionFactory.Create(tools.PreviewCancellation, "preview_cancellation"),
],
};
// Wall-clock limit for the whole agent turn
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(5));
ChatResponse response = await chat.GetResponseAsync(
[
new(ChatRole.System, "You are the Bookstore assistant. You can list the customer's orders and prepare cancellation previews."),
new(ChatRole.User, message),
], options, timeout.Token);
// If the budget cut the request, the model gets no final round to write an answer; the user sees a fixed message
string answer = response.Text.Trim() is { Length: > 0 } text
? text.ReplaceLineEndings(" ")
: "I prepared what I could. Please check the cancellation cards below.";
Console.WriteLine($"answer: {answer}");
// UI: Ada confirms the first preview, grace tries the same confirmation id
CancellationService cancellations = new(store, pending, audit);
string? firstConfirmation = pending.ForUser(ada.Id).FirstOrDefault()?.ConfirmationId;
if (firstConfirmation is not null)
{
cancellations.Confirm(firstConfirmation, new CurrentUser("grace"));
cancellations.Confirm(firstConfirmation, ada);
cancellations.Confirm(firstConfirmation, ada);
}
$ dotnet run -- "Please cancel all of my orders."
audit {"at":"2026-11-13T21:59:14.656381+00:00","userId":"ada","tool":"get_my_orders","arguments":"{}","decision":"invoked: [{\"id\":1042,\"bookTitle\":\"Refactoring\",\"total\":650,\"status\":\"Processing\"},{\"id\":1044,\"bookT...","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.623035+00:00","userId":"ada","tool":"preview_cancellation","arguments":"{\"orderId\":1042}","decision":"invoked: {\"id\":1042,\"bookTitle\":\"Refactoring\",\"refund\":650,\"next\":\"awaiting_customer_confirmation\",...","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.623685+00:00","userId":"ada","tool":"preview_cancellation","arguments":"{\"orderId\":1044}","decision":"invoked: {\"id\":1044,\"bookTitle\":\"Clean Code\",\"refund\":480,\"next\":\"awaiting_customer_confirmation\",\"...","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.623971+00:00","userId":"ada","tool":"preview_cancellation","arguments":"{\"orderId\":1045}","decision":"invoked: {\"id\":1045,\"bookTitle\":\"Domain-Driven Design\",\"refund\":1450,\"next\":\"requires_support_revie...","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.624271+00:00","userId":"ada","tool":"preview_cancellation","arguments":"{\"orderId\":1046}","decision":"invoked: {\"error\":\"Too many cancellation requests. Try again later.\"}","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.624837+00:00","userId":"ada","tool":"preview_cancellation","arguments":"{\"orderId\":1047}","decision":"denied: tool call budget exhausted","approvedBy":null}
answer: I prepared what I could. Please check the cancellation cards below.
audit {"at":"2026-11-13T22:00:53.630027+00:00","userId":"grace","tool":"confirm_cancellation","arguments":"b71e2142","decision":"denied: unknown, expired or not owned","approvedBy":null}
audit {"at":"2026-11-13T22:00:53.630169+00:00","userId":"ada","tool":"confirm_cancellation","arguments":"order 1042","decision":"cancelled","approvedBy":"ada (app button)"}
audit {"at":"2026-11-13T22:00:53.630182+00:00","userId":"ada","tool":"confirm_cancellation","arguments":"b71e2142","decision":"denied: unknown, expired or not owned","approvedBy":null}
Let's read the audit lines top to bottom. qwen3:4b first listed the orders, then in the second round asked for five previews at once. 1042 and 1044 are ready for the customer's confirmation. 1045 exceeded the amount threshold and went to support review; no cancellation was even prepared. For 1046 the rate limiter kicked in; the three previews per hour were used up. The model also tried 1046, which had already shipped; without the rate limit, the status check would have refused it. 1047 hit the total call budget, and context.Terminate ended the loop. That is why the model had no final round to write an answer; the user sees a fixed message, and the preview cards are in the UI.
On the confirmation side, grace came with Ada's confirmation id and was refused; the record was not deleted, because Take checks ownership first. Ada confirmed, 1042 was cancelled, and the audit line says who approved it. Sending the same confirmation a second time was refused. In a separate run, for Ada's request "Cancel order 1043 please." the model called preview_cancellation with 1043, the tool returned "Order 1043 was not found for this customer." and the answer was that same sentence; not even the existence of grace's order leaked.
The audit trail's fields match the 2026 document's monitoring advice: who, which tool, which arguments, what was decided, who approved. Writing refused attempts matters as much as writing successful ones; the four "denied" lines above show, in an incident review, what was attempted and what stopped it. In production these lines go not to Console but to an immutable audit target with restricted access.
Mapping to the Agentic Top 10
In post 1 we said that the moment the model turns from a component into an actor, the risk moves to the Agentic Top 10, the ASI list. The 2026 PDF's mapping table links Excessive Agency particularly to two ASI entries. ASI02 Tool Misuse & Exploitation: what happens when tools carry more functionality than the task needs; our manage_orders draft and how we narrowed it. ASI03 Identity & Privilege Abuse: tools connecting downstream with generic, over-privileged identities; our service account, the assistant_reader role and the user's token. The mapping table also mentions ASI08 Cascading Failures; the rate limit and budget that stop tool calls are exactly the circuit breakers that cut a failure before it cascades.
Today the Bookstore assistant is a single agent. If tomorrow it moves to a multi-agent design that hands tasks to a "refund agent", ASI07 Insecure Inter-Agent Communication and preserving the user's authorization scope across agents come into play. For tools connected over MCP, we covered confused deputy, token passthrough and sandboxing in post 13 of the AI Agents and MCP series; the principles here apply equally to those tools.
Production Notes
Document and review the tool inventory and each tool's identity: which downstream system does the tool reach, with which identity, with which permissions? This list may change every sprint; the startup check from post 1 catches new tools, but a human should verify that the identities are right.
Tie the limits to metrics. Calls per tool, requests hitting the rate limit, requests whose budget ran out and operations sent to a human because of the amount threshold should all be metrics. If the share of requests running out of budget jumps in a day, either the model's behavior changed or someone is probing the system.
Design the graduated policy together with the product. The threshold values, which action passes automatically and which goes to a human, are as much a business decision as a security setting. If the support team's review queue has no capacity, the threshold in practice turns into "approve everything automatically".
Account for approval fatigue. A system that asks for approval on every small action teaches the user to press "Confirm" without thinking. Reserve approval for irreversible or high-impact operations, and show the full operation on the card.
Common Pitfalls
First, writing open-ended tools. Tools like run_sql(query), call_api(url) or manage_orders(action, ...) give the model a language; that language becomes the injection's language as well. Write each job as a separate tool with its own schema.
Second, thinking you limited the service account's permissions with the tool's code. The code may be correct today; enforcing permissions in the database and the downstream API also protects you against the buggy code written tomorrow.
Third, asking for approval with the model's sentence. If the information on a screen like "The model wants to cancel 1042, do you approve?" is produced from the model's arguments, what gets approved is the model's claim. Build the card from the server's own data.
Fourth, relying on MaximumIterationsPerRequest alone. The model can ask for five tools in a single round; the run above did exactly that. Next to the round limit you also need a total call budget and a time limit.
A Small Experiment
★☆☆ Set ToolBudget's maxCalls to 2 and send the same request again. How many lines do you see in the audit trail, and which message goes to the user?
★★☆ Turn the amount threshold from a single order into a daily total refund amount per user: once the total of previews prepared on the same day exceeds 1,500 TL, new previews go to support review. Take the time from TimeProvider and test with FakeTimeProvider.
★★★ Try UserAccessTokenHandler in a real flow: stand up the Orders API as a separate minimal API, add JWT bearer authentication, and verify that when the assistant asks for grace's order with Ada's token, the Orders API returns 403 or 404. Does the result change when you deliberately remove the ownership filter inside the tool?
We narrowed the privileges in the model's hands; what remains is the text the model produces. In the next post we move to LLM10 Improper Output Handling and LLM07 Misinformation: treating model output as untrusted input, rendering Markdown safely in Angular, cutting off image links that carry data out with CSP, and validating structured output with domain rules.