Module 8 · Agents & Multi-Agent Orchestration ⏱ 18 min

Excessive Agency: The Senior-Level Failure Mode

By the end of this lesson you will be able to:
  • Recognise excessive agency (OWASP LLM06) as an engineering grant, not a model misbehaviour
  • Apply least privilege by exposing only the narrow tools a task needs
  • Gate tool calls with a deny-by-default permission check that runs before the side effect

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
Excessive agency: an agent handed more tools than its task needs will eventually use the extra ones.

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
Three levers to constrain agency, in rising order of cost: narrow tools, permission checks, human confirmation.
allowedTools exposes only the tools a role may use, so a viewer never even sees erase. Press Run.
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.

can is deny by default: an empty role holds no capability at all. Press Run.
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
The runtime gate: check the capability first, run only if it is held, deny otherwise.
guardedCall checks the capability before running. A viewer is denied; an editor runs. Press Run.
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 })));
Exercise

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'));
Exercise

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
}
Exercise

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
}
Exercise

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 };
}
Exercise

Which design best limits an agent's agency, in the spirit of OWASP LLM06?

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 general runSql), and use allowedTools so 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: guardedCall checks the capability BEFORE calling tool.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.

Checkpoint quiz

An agent whose only job is to summarise email is wired to a library that can also delete messages, and it deletes one. What went wrong?

A permission function returns true for any capability it does not recognise. What is the consequence?

Go deeper — technical resources