Most agent frameworks hand the model a flat list of tools and trust it to call only what’s appropriate. That breaks the moment an untrusted prompt injection reaches your agent. Building explicit permission scopes AI agent tools is the difference between a demo and a system you can put in production.
Step 1: Define tools as scoped capabilities
A tool is not just a function; it is a capability that carries authorization requirements. Encode those requirements next to the implementation so they cannot drift. Use a registry that maps tool names to required scopes and the underlying callable.
from dataclasses import dataclass
from typing import Callable, List, Any
@dataclass
class Tool:
name: str
scopes: List[str]
fn: Callable[..., Any]
registry: dict[str, Tool] = {
"get_user": Tool(
"get_user",
["user:read"],
lambda user_id: db.users.get(user_id),
"Fetch a user record by ID",
),
"delete_user": Tool(
"delete_user",
["user:write", "destructive"],
lambda user_id: db.users.delete(user_id),
"Permanently remove a user",
),
"send_email": Tool(
"send_email",
["email:send"],
lambda to, body: smtp.send(to, body),
"Send an outbound email",
),
}
Scope naming conventions
Use colon-separated namespaces: resource:action. Add a destructive tag for irreversible operations. This registry is the single source of truth for permission scopes AI agent tools, and every later step reads from it.
Step 2: Attach scopes to the execution context
Scopes are properties of the caller, not the model. Pull them from your identity layer and inject them into the agent runtime per request. Fail closed: no scopes means no tools.
from dataclasses import dataclass
from typing import Set
@dataclass
class AgentContext:
user_id: str
scopes: Set[str]
def context_from_jwt(token: str) -> AgentContext:
claims = decode_jwt(token) # your auth lib
return AgentContext(
user_id=claims["sub"],
scopes=set(claims.get("scopes", [])),
)
Async context propagation
In async frameworks, store the context in a contextvar so tool dispatchers down the call stack can read it without threading it manually.
import contextvars
ctx_var: contextvars.ContextVar[AgentContext] = contextvars.ContextVar("agent_ctx")
async def handle_request(req):
ctx = context_from_jwt(req.headers["authorization"])
ctx_var.set(ctx)
# ... run agent
Step 3: Filter the tool list before sending to the model
The model should only see tools the caller is allowed to use. Filter the registry by subset check and build the function schema from the visible set.
def visible_tools(ctx: AgentContext) -> List[Tool]:
return [t for t in registry.values() if set(t.scopes) <= ctx.scopes]
def openai_functions(ctx: AgentContext):
return [
{"name": t.name, "description": t.description, "parameters": {}}
for t in visible_tools(ctx)
]
Filtering reduces the chance of a successful injection because the model literally cannot emit a forbidden tool name in its JSON. However, filtering is not enforcement—cached schemas or multi-turn leaks can still surface old tools.
Step 4: Enforce scopes at the dispatch boundary
Every tool invocation must pass through a dispatcher that re-checks scopes. This is your real security control.
def dispatch(tool_name: str, args: dict, ctx: AgentContext):
tool = registry.get(tool_name)
if not tool:
raise ValueError(f"unknown tool {tool_name}")
missing = set(tool.scopes) - ctx.scopes
if missing:
log.warning("scope_violation", extra={
"user": ctx.user_id, "tool": tool_name, "missing": list(missing)
})
raise PermissionError(f"missing scopes: {missing}")
return tool.fn(**args)
Decorator alternative
If you prefer inline enforcement, wrap each tool function with a scope guard:
from functools import wraps
def require_scopes(*scopes):
def deco(f):
@wraps(f)
def inner(*a, **kw):
ctx = ctx_var.get()
if not set(scopes) <= ctx.scopes:
raise PermissionError(f"needs {scopes}")
return f(*a, **kw)
return inner
return deco
If you route model traffic through an OpenAI-compatible gateway such as n4n.ai, keep this enforcement in your tool layer—the gateway handles provider fallback and metering but doesn’t know your internal tool semantics.
Step 5: Split mutating and read-only scopes
Read-only scopes (db:read) should be widely issuable. Mutating scopes (db:write) and especially destructive must be narrow. Issue high-risk scopes only after step-up authentication.
{
"role": "support_bot",
"scopes": ["user:read", "ticket:write"]
}
{
"role": "admin_script",
"scopes": ["user:read", "user:write", "destructive"]
}
Temporary scope elevation
For human-in-the-loop approval, mint a short-lived token that adds a scope for one call:
def elevate(ctx: AgentContext, extra: List[str], ttl_sec: int) -> str:
claims = {"sub": ctx.user_id, "scopes": list(ctx.scopes | set(extra)), "exp": time.time()+ttl_sec}
return sign_jwt(claims)
Step 6: Add structured logging and alerting
Emit a machine-readable event for every dispatch attempt, allowed or denied.
import json, time
def log_call(allowed: bool, ctx: AgentContext, tool_name: str, reason: str):
print(json.dumps({
"ts": time.time(),
"user": ctx.user_id,
"tool": tool_name,
"allowed": allowed,
"reason": reason,
"scopes": list(ctx.scopes),
}))
Alerting on denial spikes
A single denied call may be a bug. Fifty denied calls in a minute from one session indicates an injection attempt. Route denial logs to a metric and page on threshold breach.
Step 7: Write tests that simulate injection
Permission scopes AI agent tools are only real if they survive adversarial input. Write unit and integration tests that mimic a poisoned model output.
def test_injection_blocked():
ctx = AgentContext(user_id="test", scopes={"user:read"})
malicious = {"name": "delete_user", "args": {"user_id": "123"}}
try:
dispatch(malicious["name"], malicious["args"], ctx)
assert False, "should have raised"
except PermissionError as e:
assert "user:write" in str(e)
Property-based testing
Use a fuzzer to generate random tool names and args; assert that any name not in visible_tools(ctx) raises at dispatch.
from hypothesis import given, strategies as st
@given(st.text())
def test_unknown_tool_rejected(tool_name):
ctx = AgentContext("u", {"user:read"})
if tool_name not in registry:
try:
dispatch(tool_name, {}, ctx)
assert False
except (ValueError, PermissionError):
pass
Verify success
You have implemented the scopes correctly when the following hold:
- A session with only
user:readreceives a function list containingget_userbut notdelete_user. - Calling
dispatch("delete_user", {"user_id":"x"}, read_only_ctx)raisesPermissionErrorand emits a denial log. - The pytest suite including
test_injection_blockedand the property test passes in CI. - A concurrency test with 1000 isolated contexts shows no scope leakage across sessions.
Manual check with curl:
curl -H "Authorization: Bearer $READONLY_TOKEN" \
-d '{"tool":"delete_user","args":{"user_id":"x"}}' \
http://localhost:8000/agent/tool
# Expect HTTP 403 and {"error":"missing scopes: ['user:write', 'destructive']"}
If you get a 200, the dispatch boundary is not enforced—do not ship. Building permission scopes AI agent tools this way moves trust from the model to your code. The model suggests; your runtime decides. That is the only pattern that holds when the prompt is hostile.