Agent Permission Scope Design: Beyond RBAC for Autonomous Systems
Last quarter, a Fortune 500 logistics company deployed an internal AI agent to manage shipment records. The agent was granted a standard `db_writer` role — the same role used by their microservices. Within 48 hours, the agent had interpreted a vague user instruction as "clean up old records" and deleted 14,000 rows from a shared tracking table. The role had `DELETE` on every table in the database. Nobody had considered that an agent with autonomous decision-making needs fundamentally different permission boundaries than a deterministic service.
The Problem: Roles Don't Model Intent
Traditional Role-Based Access Control (RBAC) was designed for humans and predictable services. A human analyst with `db_writer` knows not to drop tables. A microservice with `db_writer` runs fixed queries vetted through code review. An AI agent with `db_writer` is a probabilistic system that may take actions its designers never anticipated.
The core issue is that RBAC binds permissions to identity, not to context. An agent's legitimate needs change with each task. A research agent summarizing documents doesn't need write access. The same agent, asked to update a knowledge base, does — but only to a specific collection, for a limited time, with constraints on document size and rate.
Three failure modes repeat across the industry:
1. Over-scoping: Granting broad roles because fine-grained scoping is tedious. The agent gets `admin` "just in case."
2. Static scoping: Permissions that don't expire or adapt to task boundaries. An agent retains write access long after the task that justified it.
3. No revocation path: No mechanism to invalidate a compromised or misbehaving agent's credentials without rotating keys for every agent in the fleet.
Capability Tokens: Scoping by Delegation
The solution is to move from role-based identity to capability-based delegation. Instead of asking "who is this agent?" and looking up its roles, we ask "what can this specific token do?" The token itself carries the permission scope.
Think of it like a valet key. When you hand your car to a valet, you don't give them your full keychain with house keys and safe deposit box keys. You give a single key that starts the engine but can't open the trunk or glovebox. And you implicitly revoke it when you drive away.
For agents, this means:
- **Per-task tokens**: Each agent invocation gets a fresh capability token scoped to exactly what that task requires.
- **Wildcard resource matching**: Scopes use glob patterns so `filesystem:/workspace/agent-42/*` grants access only within that agent's workspace.
- **Time-bound expiry**: Tokens expire automatically — no manual cleanup needed.
- **Constraint attachment**: Scopes carry numeric limits (max file size, rate cap, row count) enforced at the gateway.
- **Revocable by nonce**: Each token has a unique identifier that can be blacklisted instantly.
Implementation in Pure Python
Here's a working capability token system using only the standard library:
import json, hmac, hashlib, time, fnmatch
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class Action(Enum):
READ = "read"
WRITE = "write"
EXECUTE = "execute"
DELETE = "delete"
@dataclass
class Scope:
"""A single permission boundary: resource pattern + allowed actions + constraints."""
resource: str # glob pattern, e.g. "db:shipments/*"
actions: set[Action]
constraints: dict[str, Any] = field(default_factory=dict)
def matches(self, resource: str, action: Action, ctx: dict) -> bool:
if not fnmatch.fnmatch(resource, self.resource):
return False
if action not in self.actions:
return False
# Enforce numeric constraints: max_rows, max_file_size, etc.
for key, limit in self.constraints.items():
actual = ctx.get(key)
if actual is not None and actual > limit:
return False
return True
class CapabilityManager:
def __init__(self, signing_key: bytes):
self._key = signing_key
self._revoked: set[str] = set()
def issue(self, agent_id: str, scopes: list[Scope], ttl: int = 3600) -> str:
"""Mint a signed, time-bound capability token for an agent."""
nonce = hashlib.sha256(f"{agent_id}{time.time()}".encode()).hexdigest()[:16]
payload = json.dumps({
"agent_id": agent_id,
"scopes": [{"resource": s.resource,
"actions": [a.value for a in s.actions],
"constraints": s.constraints} for s in scopes],
"issued_at": time.time(),
"expires_at": time.time() + ttl,
"nonce": nonce,
}, sort_keys=True)
sig = hmac.new(self._key, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def authorize(self, token_str: str, resource: str,
action: Action, ctx: dict | None = None) -> tuple[bool, str]:
"""Verify token signature, expiry, revocation, and scope match."""
ctx = ctx or {}
try:
payload_str, sig = token_str.rsplit(".", 1)
except ValueError:
return False, "malformed token"
expected = hmac.new(self._key, payload_str.encode(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return False, "invalid signature"
payload = json.loads(payload_str)
if time.time() > payload["expires_at"]:
return False, "token expired"
if payload["nonce"] in self._revoked:
return False, "token revoked"
for s in payload["scopes"]:
scope = Scope(s["resource"],
{Action(a) for a in s["actions"]},
s.get("constraints", {}))
if scope.matches(resource, action, ctx):
return True, f"granted via {s['resource']}"
return False, f"no scope matches {resource}:{action.value}"
def revoke(self, nonce: str) -> None:
self._revoked.add(nonce)
Usage in practice — note how scopes are built per task, not per agent:
mgr = CapabilityManager(b"super-secret-signing-key")
# Task: summarize Q2 shipment reports — read-only, one directory, 2-hour TTL
read_scopes = [Scope("filesystem:/reports/2025-q2/*", {Action.READ})]
token = mgr.issue("agent-42", read_scopes, ttl=7200)
ok, reason = mgr.authorize(token, "filesystem:/reports/2025-q2/shipments.csv",
Action.READ)
print(ok, reason) # True, "granted via filesystem:/reports/2025-q2/*"
# Same token cannot write, cannot read outside the pattern
ok, reason = mgr.authorize(token, "filesystem:/reports/2025-q1/shipments.csv",
Action.READ)
print(ok, reason) # False, "no scope matches ..."
# Task: update knowledge base — write, but capped at 50 rows per operation
write_scopes = [Scope("db:knowledge_base/*", {Action.WRITE},
constraints={"max_rows": 50})]
token2 = mgr.issue("agent-42", write_scopes, ttl=600)
ok, reason = mgr.authorize(token2, "db:knowledge_base/articles",
Action.WRITE, ctx={"max_rows": 200})
print(ok, reason) # False — exceeds constraint of 50
Scope Design Principles
When designing scopes for your own agents, these principles have emerged from production deployments:
Narrowest viable scope. Start with read-only. Add write only when the task demonstrably requires it. If an agent needs to write to one table, don't grant write to the schema. The glob pattern is your friend — `db:shipments/` not `db:`.
Short TTLs by default. A 10-minute token that gets renewed is safer than a 24-hour token. If an agent loops or stalls, the token expires before it can do widespread damage. For long-running agents, implement a refresh protocol rather than extending TTL.
Constraints are not optional. Resource limits (max rows, max file size, rate caps) are the difference between an agent that writes one document and one that writes 10,000 in a tight loop because it misread a response. Enforce them at the authorization layer, not in agent logic.
Audit every authorization. The `authorize` method should log to an append-only store. When something goes wrong — and it will — you need to reconstruct exactly which token authorized which action on which resource at what time.
Plan for revocation from day one. Agents will misbehave. Compromised tokens will leak. The revocation set must be checked on every authorization, and revocation must propagate to all gateway instances within seconds. A Redis set with sub-second polling is sufficient for most deployments.
Key Takeaways
- RBAC binds permissions to identity; agents need permissions bound to **task context** via capability tokens.
- Use **glob-based resource patterns** to scope agents to specific paths, tables, or API endpoints — never grant blanket access.
- Attach **numeric constraints** (row limits, file sizes, rate caps) directly to scopes and enforce them at the authorization gateway.
- Default to **short TTLs** (5–60 minutes) and implement token refresh for long-running tasks.
- Build **revocation** into the core authorization path from the start — not as an afterthought.
- Log every authorization decision to an **append-only audit trail** for post-incident reconstruction.
---
For more on securing autonomous AI systems, see our Agent Runtime Security Guide and the companion post on sandboxed execution environments for LLM agents.
Written with AI assistance — reviewed by Toc Am
No comments:
Post a Comment