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 CapabilityToken dataclass) that binds an agent_id, an immutable FrozenSet[str] of permitted tool names, a hard expires_at wall-clock timestamp, and a task_hash tying 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 @role prefix 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 CapabilityToken at task-start time by resolving the task class through the PermissionRegistry and setting a hard expires_at ceiling, so agents are never pre-authorized beyond the duration of the current task.
  • ToolGate — the per-tool authorization enforcer whose authorize method checks token expiration before checking permission membership, producing a structured Result that distinguishes deny(reason="token_expired") from deny(reason="permission_missing").
  • Structured Denial Signal — a machine-readable reason string on a deny result ("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.

Loading diagram...

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

  1. Do mint a fresh CapabilityToken via DynamicScopeEngine.derive for every discrete task, scoped to its minimum task_class permissions — a single static role over-permissions every session window, so one compromised task inherits unconstrained tool access across the account (OWASP ASI03: Excessive Agency).
  2. Do check token.expired() before checking tool_name not in token.permissions inside ToolGate.authorize — this ordering is operationally significant: a token_expired denial signals a task that ran past its expected duration, while permission_missing signals a registry gap or bypass attempt, and conflating them blunts the Prometheus escalation counter's ability to group denial spikes by cause.
  3. Do expand permission bundles exclusively through the @role reference syntax in PermissionRegistry.ROLES — inlining permissions directly into multiple task classes means a single scope correction (e.g., tightening fetch_url:allowlist) must be hunted down in every entry rather than propagating from one registry edit.

Don'ts

  1. Don't assign a long-lived or reused CapabilityToken across multiple task invocationsexpires_at is 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.
  2. Don't swallow or aggregate deny(reason=...) signals from ToolGate.authorize into a generic boolean — the structured reason field (token_expired vs. 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.
  3. Don't add new tool permissions directly to a task_class entry without also defining a named role in PermissionRegistry.ROLES — bypassing the @reader/@writer/@researcher inheritance chain creates permission drift where permissions_for returns 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

All free lessons in GenAI Security Engineering