Custom route-to-scope mappings

Override route scopes and apply an explicit application namespace for agent-run authorization.

Override selected entries in the default AgentOS scope map. The included smoke checks GET /config; apply the correction below before relying on the agent-run mapping.

custom_scope_mappings.py
"""
Custom route-to-scope mappings
==============================

Override selected entries in the default AgentOS scope map and add a second
application scope to agent runs. The smoke proves the override on GET /config.

Prerequisites: none for the smoke; OPENAI_API_KEY for live agent runs
Run: .venvs/demo/bin/python cookbook/05_agent_os/07_security/custom_scope_mappings.py
Try: call GET /config with an app:read token
"""

import os
from datetime import UTC, datetime, timedelta

import jwt
from agno.agent import Agent
from agno.models.openai import OpenAIResponses
from agno.os import AgentOS
from agno.os.middleware import JWTMiddleware
from fastapi.testclient import TestClient

# ---------------------------------------------------------------------------
# Create AgentOS with custom mappings
# ---------------------------------------------------------------------------

OS_ID = "custom-scope-demo"
JWT_SECRET = os.getenv(
    "JWT_VERIFICATION_KEY", "development-secret-at-least-256-bits-long"
)

custom_scope_mappings = {
    "GET /config": ["app:read"],
    "POST /agents/*/runs": ["agents:run", "app:execute"],
    "GET /sessions": ["app:admin"],
}

security_agent = Agent(
    id="security-agent",
    name="Security Agent",
    model=OpenAIResponses(id="gpt-5.5"),
)
agent_os = AgentOS(id=OS_ID, agents=[security_agent])
app = agent_os.get_app()
app.add_middleware(
    JWTMiddleware,
    verification_keys=[JWT_SECRET],
    algorithm="HS256",
    authorization=True,
    verify_audience=True,
    scope_mappings=custom_scope_mappings,
)

def make_token(subject: str, scopes: list[str]) -> str:
    now = datetime.now(UTC)
    return jwt.encode(
        {
            "sub": subject,
            "aud": OS_ID,
            "scopes": scopes,
            "iat": now,
            "exp": now + timedelta(hours=1),
        },
        JWT_SECRET,
        algorithm="HS256",
    )

def run_smoke() -> dict[str, int]:
    custom_reader = make_token("custom-reader", ["app:read"])
    default_reader = make_token("default-reader", ["agents:read"])
    with TestClient(app) as client:
        statuses = {
            "custom_reader": client.get(
                "/config",
                headers={"Authorization": f"Bearer {custom_reader}"},
            ).status_code,
            "default_reader": client.get(
                "/config", headers={"Authorization": f"Bearer {default_reader}"}
            ).status_code,
        }
    assert statuses == {"custom_reader": 200, "default_reader": 403}, statuses
    return statuses

# ---------------------------------------------------------------------------
# Run the smoke, then serve
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    smoke_statuses = run_smoke()
    print(f"app:read GET /config: {smoke_statuses['custom_reader']}")
    print(f"agents:read GET /config after override: {smoke_statuses['default_reader']}")
    print("Agent runs require both agents:run and app:execute.")
    agent_os.serve(app=app, port=7777)

Correct the agent-run scope mapping

The current scope checker expands two-part required scopes using the route's resource namespace. On an agent-run route, app:execute is therefore checked as an agent execute permission: a token with agents:run and app:execute is rejected, while one with agents:run and agents:execute can pass. The source program's configuration smoke does not test this route.

Before app.add_middleware(...), replace the POST mapping with an explicit three-part application scope:

custom_scope_mappings["POST /agents/*/runs"] = ["agents:run", "app:*:execute"]

Add this check after make_token is defined and call it alongside run_smoke() before serving:

def check_agent_run_permissions() -> None:
    cases = [
        (["app:execute"], 403),
        (["agents:run", "agents:execute"], 403),
        (["agents:run", "app:execute"], 422),
    ]
    with TestClient(app) as client:
        for scopes, expected in cases:
            token = make_token("scope-check", scopes)
            response = client.post(
                "/agents/security-agent/runs",
                headers={"Authorization": f"Bearer {token}"},
            )
            assert response.status_code == expected, response.text

The authorized case intentionally omits the required message and reaches HTTP 422 request validation. The two denied cases remain 403; no model call is needed for this permission check.

Run the Example

Set up your virtual environment

uv venv --python 3.12
source .venv/bin/activate

Install dependencies

uv pip install -U "agno[os]" openai

Export your OpenAI API key

export OPENAI_API_KEY="your_openai_api_key_here"

Run the example

Save the code above as custom_scope_mappings.py, then run:

python custom_scope_mappings.py

Full source: cookbook/05_agent_os/07_security/custom_scope_mappings.py