Every agent failure you have built so far has been a loop that does not stop, a handoff that drops state, a dependency that fails. This one is different, and it is the failure mode that separates a junior agent builder from a senior one: excessive agency. The agent is not buggy and the model is not misbehaving. The engineer simply handed the agent more capability than its task required, and the model used what it was given.
Picture a summarise-my-inbox agent. Its job is to read mail and write a summary. But you wired it up with the same mail library you use everywhere, which can also send and delete. One confused tool call later, a message is gone. The model did exactly what the tools allowed. The bug was granting those tools in the first place. This is OWASP's LLM06: the agent has been given too much agency, and the fix is to grant less, on purpose.
flowchart LR TASK["task: summarise email"] --> AGENT["agent"] TOOLS["tools granted: read, send, delete"] --> AGENT AGENT --> RISK["more capability<br/>than the task needs"] style RISK fill:#b91c1c,color:#fff
Grant the least capability that does the job
The first lever is the tools themselves. A tool should do the minimum its purpose requires. If an agent only needs to look things up, give it a read-only search tool, not a general runSql that can SELECT, UPDATE, and DROP. Narrow tools are not a convenience, they are a security boundary: a model cannot call a capability it was never offered.
This is the principle of least privilege applied to agents. Split a fat 'manage records' tool into a safe get and a dangerous erase, each declared with the capability it needs, and you have removed most of the risk before any permission check runs. The tool surface IS the agent's agency, so design it small and never hand over a generic 'do anything' tool because it happened to be convenient.
flowchart TD A["constrain agency"] --> B["least-privilege tools<br/>read tool, not SQL exec"] A --> C["permission checks<br/>deny by default"] A --> D["confirm irreversible acts<br/>delete, send, spend"] style A fill:#8b5cf6,color:#fff
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function allowedTools(tools, roleCaps) {
const out = {};
for (const name in tools) {
const t = tools[name];
if (!t.requires || can(roleCaps, t.requires)) out[name] = t;
}
return out;
}
const registry = {
search: { requires: 'read', run: () => 'rows' },
summary: { run: () => 'summary' },
erase: { requires: 'write', run: () => 'erased' }
};
const viewerTools = allowedTools(registry, ['read']);
const editorTools = allowedTools(registry, ['read', 'write']);
console.log('viewer sees: ' + Object.keys(viewerTools).join(','));
console.log('editor sees: ' + Object.keys(editorTools).join(','));
Permission checks: deny by default
Least-privilege tools shrink what exists; a permission check decides what a given run may actually use. Each tool declares a requires capability, and the agent's role carries a set of granted capabilities. Before a tool runs, a can(roleCaps, cap) check asks whether the role holds that capability. The rule that makes this safe is deny by default: an unknown capability, an empty role, a missing entry all return false. Allow-list what is permitted, and never try to deny-list everything that is forbidden.
Deny by default is the whole game. A permission function that returns true for anything it does not recognise is decorative, it approves everything, and the first confused tool call sails straight through. The check has to fail closed: when in doubt, refuse the call.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
const viewer = ['read'];
const editor = ['read', 'write'];
console.log('viewer read: ' + can(viewer, 'read'));
console.log('viewer write: ' + can(viewer, 'write'));
console.log('editor write: ' + can(editor, 'write'));
console.log('nobody write: ' + can([], 'write'));
Gate at run time, and confirm the irreversible
The permission check belongs at the moment of the call, not afterwards. guardedCall looks up the tool, checks its requires against the role, and only invokes tool.run if the capability is held; otherwise it returns { denied: true } without ever running the tool. Ordering is everything: run the check before the side effect, or the guard is theatre. A denied call must never have happened.
The third lever is human-in-the-loop confirmation for actions that cannot be undone. A permission check can authorise a delete in general; only a person can decide whether this delete, right now, is the right one. For irreversible acts, deleting a record, sending a message, spending money, the agent proposes and a human approves. Narrow tools, permission checks, and human confirmation are the three layers, in rising order of cost and certainty.
flowchart TD CALL["call tool delete"] --> CHK["can role do write?"] CHK -->|"yes"| RUN["run the tool"] CHK -->|"no"| DENY["deny, never run it"] style RUN fill:#166534,color:#fff style DENY fill:#b91c1c,color:#fff
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function guardedCall(tools, roleCaps, name, args) {
const tool = tools[name];
if (tool.requires && !can(roleCaps, tool.requires)) {
return { denied: true, reason: 'missing capability: ' + tool.requires };
}
return { ran: true, result: tool.run(args) };
}
const tools = {
get: { requires: 'read', run: () => 'row 42' },
del: { requires: 'write', run: (a) => 'deleted ' + a.id }
};
console.log(JSON.stringify(guardedCall(tools, ['read'], 'del', { id: 9 })));
console.log(JSON.stringify(guardedCall(tools, ['read', 'write'], 'del', { id: 9 })));
What does this print? can is deny by default: it returns true only when the capability is present in the role's set.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
console.log(can(['read'], 'read'));
console.log(can(['read'], 'write'));
console.log(can([], 'read'));
['read'] contains 'read', so the first is true.
['read'] does not contain 'write', and [] contains nothing, so both are false.
Write allowedTools(tools, roleCaps) that returns a NEW object containing only the tools the role may use. A tool is included if it has no requires field, or if can(roleCaps, tool.requires) is true. The can helper is already defined for you.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function allowedTools(tools, roleCaps) {
// include a tool if it has no requires, or the role holds the required capability
}
Build a new object and loop over the tools with for...in.
Include a tool when it has no requires, or when can(roleCaps, t.requires) is true.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function allowedTools(tools, roleCaps) {
const out = {};
for (const name in tools) {
const t = tools[name];
if (!t.requires || can(roleCaps, t.requires)) out[name] = t;
}
return out;
}
Write guardedCall(tools, roleCaps, name, args). Look up tools[name]. If the tool has a requires and the role does NOT hold that capability, return { denied: true, reason: 'missing capability: ' + tool.requires } WITHOUT running it. Otherwise run the tool and return { ran: true, result: tool.run(args) }. The can helper is already defined for you.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function guardedCall(tools, roleCaps, name, args) {
// check the capability first; deny without running if it is missing
}
Read tool.requires; if it is set and can returns false, return the denied object.
Only call tool.run(args) inside the success branch, never before the check.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function guardedCall(tools, roleCaps, name, args) {
const tool = tools[name];
if (tool.requires && !can(roleCaps, tool.requires)) {
return { denied: true, reason: 'missing capability: ' + tool.requires };
}
return { ran: true, result: tool.run(args) };
}
This guardedCall computes the result by calling tool.run(args) BEFORE checking the permission, so even a denied call has already performed the destructive action. Move the side effect after the check: a denied call must return { denied: true } without ever running the tool. The can helper is already defined for you.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function guardedCall(tools, roleCaps, name, args) {
const tool = tools[name];
const result = tool.run(args);
if (tool.requires && !can(roleCaps, tool.requires)) {
return { denied: true, reason: 'missing capability: ' + tool.requires };
}
return { ran: true, result };
}
The tool.run(args) call must move into the success branch, so it only runs when permitted.
When denied, return without ever calling tool.run, so the side effect never happens.
function can(roleCaps, cap) { return roleCaps.includes(cap); }
function guardedCall(tools, roleCaps, name, args) {
const tool = tools[name];
if (tool.requires && !can(roleCaps, tool.requires)) {
return { denied: true, reason: 'missing capability: ' + tool.requires };
}
return { ran: true, result: tool.run(args) };
}
Which design best limits an agent's agency, in the spirit of OWASP LLM06?
Least privilege plus a deny-by-default permission check removes capability the task never needed and gates what remains. Prompting the model to restrain itself is not a control, because the model will eventually use whatever it was given.
Recap
- Excessive agency (OWASP LLM06) is an engineering grant, not a model error: the agent does damage because it was handed more capability than its task needed.
- Least privilege: expose narrow tools (a read
get, not a generalrunSql), and useallowedToolsso a role only ever sees what it may use. - Deny by default:
can(roleCaps, cap)returns true only for capabilities the role actually holds; unknown or empty means refuse. - Gate at run time:
guardedCallchecks the capability BEFORE callingtool.run, so a denied call has no side effect. - Add human-in-the-loop confirmation for irreversible acts (delete, send, spend): a permission can authorise a class of action; only a person can authorise this one, now.
That closes the agents module. You can now build a loop, hand off state between workers, recover from failure, and, the senior move, grant the agent only the agency it actually needs.