Free lesson · GenAI Security Engineering
Enforce least-privilege for agent tool access
Build role-based tool access control, dynamic permission scoping based on task context, and privilege escalation detection.
Course: AI Security Engineering · Chapter 10 · Agentic AI Security
Free to read — no subscription required.
Introduction
Engineers often assign a single static role to an AI agent and assume it covers every task that agent might run — but static role assignments over-permission every session window, turning one compromised task into unconstrained tool access across the account. OWASP Agentic Top 10 2026 names this ASI03: Excessive Agency, and it is one of the highest-impact risks in agentic systems today. By the end of this lesson, you will know how to scope tool permissions dynamically per task using a capability-token model, enforce hard expiry when a task completes, and detect privilege-escalation attempts through structured denial signals before they become breaches.
Key Terminology
- ASI03: Excessive Agency — the OWASP Agentic Top 10 2026 risk that names the condition where an AI agent holds more tool permissions than a given task requires, so any single compromised task gains unconstrained access across every tool the static role authorized.
- Capability Token — a short-lived, task-scoped authorization object (the
CapabilityTokendataclass) that binds anagent_id, an immutableFrozenSet[str]of permitted tool names, a hardexpires_atwall-clock timestamp, and atask_hashtying the token to the specific task it was issued for. - PermissionRegistry — a registry that maps task classes such as
"reader","writer", and"researcher"to their minimal permission sets, supporting@roleprefix inheritance so shared bundles stay DRY and a single registry edit propagates to every task class that references that bundle. - DynamicScopeEngine — the component that mints a fresh
CapabilityTokenat task-start time by resolving the task class through thePermissionRegistryand setting a hardexpires_atceiling, so agents are never pre-authorized beyond the duration of the current task. - ToolGate — the per-tool authorization enforcer whose
authorizemethod checks token expiration before checking permission membership, producing a structuredResultthat distinguishesdeny(reason="token_expired")fromdeny(reason="permission_missing"). - Structured Denial Signal — a machine-readable
reasonstring on adenyresult ("token_expired"or"permission_missing") that feeds a Prometheus escalation counter, letting operators distinguish expired-window anomalies from scope-bypass attempts and treat sustained spikes as ASI03 events requiring investigation.
Concepts
Why Static Role Assignment Produces Excessive Agency
Most agentic systems are initialized with one static role — "this agent is a writer" — and that role persists silently for the lifetime of the session. Every tool call the agent makes is authorized against the same broad permission set, regardless of what the current task actually needs. A task that only requires reading a calendar entry still authorizes send_email and create_event for the duration of the session window.
OWASP Agentic Top 10 2026 names this ASI03: Excessive Agency. The threat model is direct: if an attacker injects a malicious instruction mid-session — through a prompt injection in a fetched document, for example — the agent acts on it using the full static role it was initialized with. The blast radius is bounded only by the role, not by what the current task needed. The fix is to replace the session-role concept with task-scoped permissions: every new task gets exactly the minimal permission set that task requires, and nothing more.
The Capability-Token Model: Task-Scoped, Time-Bounded Authorization
A capability token is a short-lived authorization envelope minted fresh for each task, not each session. The DynamicScopeEngine.derive call (see Code Walkthrough) executes at task-start time, resolving the task class through PermissionRegistry.permissions_for — which expands @role inheritance inline so that a "writer" task gets @reader's read permissions without granting them independently — and then sets a hard expires_at wall-clock ceiling.
The token is immutable once issued: a FrozenSet[str] of permitted tool names that cannot be amended at runtime. An agent that stalls, crashes, or is hijacked mid-task loses all permissions when the clock reaches expires_at, with no client-side cooperation required. This is the server-side boundary that makes the model robust: the capability token is the security perimeter, not the agent's own behavior.
Enforcement Ordering and Denial Signals as Telemetry
The ToolGate.authorize method checks expiration before permission membership — a deliberate ordering with operational consequences. A token past its expires_at is always rejected as token_expired, even if the requested tool name happens to be in the permission set. Reversing the order would mask timing anomalies: a task that ran past its authorized window would look identical to an ordinary scope miss, erasing the signal that something ran longer than expected.
Each structured denial reason maps to a distinct operational interpretation. token_expired points to a duration anomaly — a stuck process, an agent that was paused and resumed, or a deliberate extension attempt. permission_missing points to either a registry gap (the task class is missing a tool it legitimately needs) or a scope-bypass attempt (the agent is calling a tool it was never authorized for). Both reasons feed the same Prometheus escalation counter, but they route to different remediation paths. Sustained spikes in token_expired signal task-duration drift; sustained spikes in permission_missing are the earliest operational indicator of an ASI03 bypass attempt and warrant on-call escalation rather than silent log rotation.
Code Walkthrough
Now that you understand how task-scoped capability tokens and their hard expires_at ceiling bound the excessive-agency window, we can trace the full enforcement path from task classification through tool authorization.
The first building block is a PermissionRegistry that maps task classes to minimal permission sets, paired with a DynamicScopeEngine that mints a short-lived capability token for each task:
Code snippetpython
1import time 2import hashlib 3from dataclasses import dataclass 4from typing import FrozenSet 5 6@dataclass 7class CapabilityToken: 8 agent_id: str 9 permissions: FrozenSet[str] 10 expires_at: float 11 task_hash: str 12 13 def expired(self) -> bool: 14 return time.time() > self.expires_at 15 16class PermissionRegistry: 17 ROLES = { 18 "reader": ["read_email", "read_calendar", "read_doc"], 19 "writer": ["@reader", "send_email", "create_event"], 20 "researcher": ["@reader", "search_kb", "fetch_url:allowlist"], 21 } 22 23 def permissions_for(self, task_class: str) -> FrozenSet[str]: 24 raw = self.ROLES.get(task_class, []) 25 resolved: list[str] = [] 26 for perm in raw: 27 if perm.startswith("@"): 28 resolved.extend(self.ROLES.get(perm[1:], [])) 29 else: 30 resolved.append(perm) 31 return frozenset(resolved) 32 33class DynamicScopeEngine: 34 def __init__(self, registry: PermissionRegistry): 35 self.registry = registry 36 37 def derive(self, agent_id: str, task_class: str, duration_secs: int) -> CapabilityToken: 38 permissions = self.registry.permissions_for(task_class) 39 task_hash = hashlib.sha256(f"{agent_id}:{task_class}".encode()).hexdigest() 40 return CapabilityToken( 41 agent_id=agent_id, 42 permissions=permissions, 43 expires_at=time.time() + duration_secs, 44 task_hash=task_hash, 45 )
PermissionRegistry.permissions_for expands @reader inline so common bundles stay DRY and a single registry edit propagates everywhere. DynamicScopeEngine.derive builds a token whose expires_at is the hard ceiling on the task — even a stuck or compromised agent loses all permissions when the clock runs out, which is the hard task-scoped expiry the Concepts section describes.
The second building block is the ToolGate, which every tool checks at its own entry point before executing any action:
Code snippetpython
1@dataclass 2class Result: 3 allowed: bool 4 reason: str = "" 5 6 @staticmethod 7 def allow() -> "Result": 8 return Result(allowed=True) 9 10 @staticmethod 11 def deny(reason: str) -> "Result": 12 return Result(allowed=False, reason=reason) 13 14class ToolGate: 15 def authorize(self, tool_name: str, token: CapabilityToken) -> Result: 16 if token.expired(): 17 return Result.deny(reason="token_expired") 18 if tool_name not in token.permissions: 19 return Result.deny(reason="permission_missing") 20 return Result.allow()
Expiration is checked before permission membership — this ordering matters operationally. A token_expired denial tells operators the task ran past its expected duration; a permission_missing denial flags either a registry gap or a bypass attempt. Both structured deny reasons feed the Prometheus escalation counter, enabling the on-call alerting pipeline to group denial spikes by cause and treat sustained spikes as ASI03 events requiring investigation.
Check that ToolGate.authorize returns deny(reason="token_expired") for a token whose expires_at is in the past, and deny(reason="permission_missing") when the tool name is absent from the token's permission set — confirm both cases with a quick manual test before wiring the gate into your agent runtime.
Do's and Don'ts
Now that you have worked through the implementation, the practices below separate a durable approach from a fragile one.
Do's
- ✓Do mint a fresh
CapabilityTokenviaDynamicScopeEngine.derivefor every discrete task, scoped to its minimumtask_classpermissions — a single static role over-permissions every session window, so one compromised task inherits unconstrained tool access across the account (OWASP ASI03: Excessive Agency). - ✓Do check
token.expired()before checkingtool_name not in token.permissionsinsideToolGate.authorize— this ordering is operationally significant: atoken_expireddenial signals a task that ran past its expected duration, whilepermission_missingsignals a registry gap or bypass attempt, and conflating them blunts the Prometheus escalation counter's ability to group denial spikes by cause. - ✓Do expand permission bundles exclusively through the
@rolereference syntax inPermissionRegistry.ROLES— inlining permissions directly into multiple task classes means a single scope correction (e.g., tighteningfetch_url:allowlist) must be hunted down in every entry rather than propagating from one registry edit.
Don'ts
- ✗Don't assign a long-lived or reused
CapabilityTokenacross multiple task invocations —expires_atis the hard ceiling that strips permissions from a stuck or compromised agent; reusing a token across tasks resets that boundary to never, leaving the excessive-agency window open for the lifetime of the agent process. - ✗Don't swallow or aggregate
deny(reason=...)signals fromToolGate.authorizeinto a generic boolean — the structuredreasonfield (token_expiredvs.permission_missing) is what lets the on-call alerting pipeline distinguish clock drift from active bypass attempts; losing that string turns sustained ASI03 escalation spikes into silent noise. - ✗Don't add new tool permissions directly to a
task_classentry without also defining a named role inPermissionRegistry.ROLES— bypassing the@reader/@writer/@researcherinheritance chain creates permission drift wherepermissions_forreturns inconsistent sets across task classes that should share a common baseline.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Security Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in AI Security Engineering
- Ch 9Deploy secure vector store on GKE with network isolation
- Ch 9Monitor vector store access patterns and anomalies
- Ch 10Enforce least-privilege for agent tool accessYou are here
- Ch 10Detect and contain rogue agent behavior
- Ch 10Deploy agent security monitor on GKE
- Ch 10Test agent security with adversarial scenarios
- Ch 11Detect tool poisoning in MCP tool descriptions