Lesson 16 of 21Track 2

Agent safety and control

Permission scopes per agent

Principle of least privilege for agents.

Interactive exercise ~10 min

Allow-listing the tool is not enough

The previous lesson scoped which tools an agent can call. This lesson is about which arguments are allowed within a tool call. "The agent can call query_db" is not the same as "the agent can run any SQL it wants on any table." The interesting safety boundary is usually the second one.

Permission scopes attach argument-level constraints to allowed tools. Done right, an agent's tools are still useful but the blast radius of any single call is bounded.

The shape

A permission scope is a predicate over a tool call's arguments:

@dataclass
class Scope:
    tool: str
    allow: callable   # (args: dict) -> bool
    reason: str       # human-readable explanation if denied

When the agent tries to call a tool, the executor runs every applicable scope. If any scope denies the call, the tool does not run.

def execute(call, scopes):
    for scope in scopes:
        if scope.tool != call.name:
            continue
        if not scope.allow(call.args):
            return {"status": "denied", "reason": scope.reason}
    return TOOL_REGISTRY[call.name](**call.args)

The simplest scopes are static: the call has to look like X. Richer scopes consult the session, the agent, or the current state.

Examples that are not optional

These scopes show up in almost every production agent system. If you do not have them, you have a bug waiting:

query_db scoped to read-only

Scope(
    tool="query_db",
    allow=lambda args: not WRITE_KEYWORDS.search(args["query"]),
    reason="this agent can only read from the database",
)

A code agent that can call query_db but only with SELECT statements. Any UPDATE, DELETE, or DROP is denied. This single scope prevents a huge class of accidents.

read_file scoped to a directory

Scope(
    tool="read_file",
    allow=lambda args: args["path"].startswith("/workspace/"),
    reason="reads outside /workspace are not allowed",
)

Prevents the agent from reading /etc/passwd or other sensitive files even if a malicious prompt suggests it. Path validation is checked against the resolved path, not the input string, to handle .. traversal.

send_email scoped to specific recipients

Scope(
    tool="send_email",
    allow=lambda args: args["to"].endswith("@yourcompany.com"),
    reason="emails can only go to internal addresses",
)

Blocks the agent from emailing arbitrary external addresses. For customer-facing agents, the allow list might be "addresses on the user's verified contacts list."

deploy scoped to staging only

Scope(
    tool="deploy",
    allow=lambda args: args["env"] != "production",
    reason="this agent can only deploy to non-prod environments",
)

Production deploys go through a different agent (or a human). Same deploy tool, different scope per agent.

Composing scopes

Scopes compose as AND: every applicable scope must allow the call. Two scopes on query_db (read-only AND limited tables) means the call has to satisfy both.

DEFAULT_SCOPES = [
    Scope("query_db", read_only_query, "no writes"),
    Scope("query_db", limited_tables_query, "only the orders table"),
    Scope("read_file", inside_workspace, "/workspace only"),
    Scope("send_email", internal_recipient, "internal addresses only"),
]

You can also compose scopes hierarchically: a session scope that applies to everyone, plus an agent scope that further restricts. The intersection is what matters.

Argument validation versus output validation

Permission scopes constrain inputs: what the agent is allowed to ask for. They do not constrain outputs. If the agent runs query_db("SELECT * FROM users WHERE id=1") and the result contains a password hash, scopes did not protect that.

Output filtering is a separate layer (covered in lesson 4 of this module: input/output guardrails). Scopes and output filters are complements, not substitutes.

Per-call versus session-wide budget scopes

Some constraints are not "this call OK / not OK" but "the agent has done this enough times already today." These are budget scopes:

@dataclass
class BudgetScope:
    tool: str
    max_per_session: int
    max_per_minute: int
    counters: dict = field(default_factory=dict)
 
 
def allow(self, call):
    if self.counters.get("session", 0) >= self.max_per_session:
        return False, f"hit session limit of {self.max_per_session}"
    return True, "ok"

Budget scopes catch runaway loops: an agent stuck in a retry storm calling send_email 200 times. The static scope says "internal recipients only," which is fine. The budget scope says "max 10 sends per session," which is what actually saves you.

For tools that touch external services with rate limits or money cost, budget scopes are mandatory.

Where scopes live

The clean place is alongside the tool registry, in a configuration the agent doesn't directly read:

TOOL_REGISTRY = {...}
 
SCOPES_BY_AGENT = {
    "read-only-research": [
        Scope("query_db", read_only_query, "..."),
        Scope("read_file", inside_workspace, "..."),
    ],
    "ops": [
        Scope("deploy", staging_only, "..."),
        BudgetScope("rotate_credential", max_per_session=2),
    ],
}

The agent does not see the scope definitions; it only experiences denials when its calls violate them. This is the same defense-in-depth pattern as the allow list: enforcement is the executor's job, not the agent's.

What scope failures should look like to the agent

When a scope denies a call, the agent's next observation should be informative:

{
    "status": "denied",
    "reason": "query_db rejected: writes are not allowed for this agent",
    "hint": "rephrase as a SELECT or escalate to a write-capable agent",
}

Specific reasons let the model self-correct. Vague denials ("permission error") just produce confused retries.

Common failure modes

Scopes that pass when they shouldn't

Scope predicates that are too lax. The classic is "block writes by checking for UPDATE keyword" while missing INSERT, DELETE, DROP. Use a real SQL parser, not a regex. Same idea for path traversal: resolve the path before checking, do not trust the string.

Scopes that block when they shouldn't

Over-strict scopes that deny legitimate use cases. The agent retries, fails again, eventually gives up. You see this in your logs as a high denial rate from a specific scope. If a scope is denying more than a few percent of calls, it is probably wrong, either the scope or the agent's prompt.

Scopes that depend on mutable state

A scope that consults a counter has to be thread-safe and persistent. If you are checkpointing the loop (last lesson), include scope counters in the snapshot, otherwise a resumed loop forgets it has already used its budget.

Scopes scale better than scolding

A common but bad pattern is to put restrictions in the system prompt: "do not call query_db with anything other than SELECT statements." Models follow that... most of the time. Scopes don't follow it most of the time, they follow it 100% of the time. Move policy into the executor wherever you can.

Key takeaway

Permission scopes constrain which arguments are allowed for each tool, layered on top of the allow list. Common ones (read-only DB, restricted paths, internal-only email, non-prod deploys) catch entire classes of mistakes. Compose them as AND, treat budget scopes as mandatory for tools with rate or cost limits. Make denials informative so the agent can recover. The next lesson handles the case where the right answer is not "allow" or "deny" but "ask a human first."

>_permission-scopes.py
Loading editor...
Output will appear here.

Done with this lesson?