n4nAI

How to build permission scopes for AI agent tool access

Practical guide to building permission scopes AI agent tools: model tools as scoped capabilities, enforce at dispatch, filter by session, test against injection.

n4n Team3 min read605 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. A session with only user:read receives a function list containing get_user but not delete_user.
  2. Calling dispatch("delete_user", {"user_id":"x"}, read_only_ctx) raises PermissionError and emits a denial log.
  3. The pytest suite including test_injection_blocked and the property test passes in CI.
  4. 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.

Tagspermissionsai-agent-securitytool-callingaccess-control

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ai agent security & prompt injection defense posts →