Write permission rules¶
Ask before a tool runs showed AllowByReadOnly() and DecideByHuman(). This page is for
the rest: how permissions decide, the other built-in rules, and rules of your own.
How permissions decide¶
For each call of the turn, in the order the model asked for them:
- Every
DenyPermissionin the list, in list order, wherever it is in the list. The first one that refuses the call decides. No allow rule can let a refused call through. - Otherwise the
AllowPermissions andDecidePermissions, in list order. The first one with an opinion decides. - Nobody decided: the call is refused.
So [AllowByDefault(), DenyByName(["delete_*"])] allows every call except those to delete_* tools. A call nobody
decides about is refused with No permission allowed this call., and Python shows a PermissionWarning that names
the tool. permissions=[] refuses every call.
permissions=None (or leaving it out) turns the checks off: every call runs. agent.copy(permissions=None) does that
for a copy.
A refused call does not run. The model gets the reason as its error result. state.history has a tool_result entry
with outcome == "denied", and the Reporter gets on_tool_end with outcome.kind == "denied" and
outcome.decided_by set to the repr() of the permission that refused it (None when nobody decided). Calls to
unknown tools and calls with invalid JSON arguments are input errors
before permissions are asked.
Built-in permissions¶
They live in alpineagents.permissions:
| Permission | Kind | Decides |
|---|---|---|
DenyByName(["delete_*"], reason=None) |
Deny | Refuses calls to matching tools, with reason or "{name} is not allowed." |
AllowByName(["read_file", "github__get_*"]) |
Allow | Allows calls to matching tools |
AllowByReadOnly(trust_mcp=False) |
Allow | Allows a call the tool says is read-only: tool.hints_for(call.args).read_only |
DecideByHuman(human=None) |
Decide | Asks the person: yes allows, no refuses and stops |
AllowByDefault() |
Allow | Allows every call. Put it last |
- Names are
fnmatchpatterns on the name the model calls:"delete_*"matchesdelete_fileanddelete_branch. - Put
DecideByHuman()after the permissions that decide without asking, so the person is asked only about the calls they left open. DecideByHuman(human=...)asks someone else, for example an approvals channel. ADecideByHuman()with nohumanof its own, on an Agent withhuman=None, raisesNoHumanErrorwhen the run starts.
Say what a tool does¶
Four hints say what a tool does. The model does not see them. Permissions read them:
from pathlib import Path
from alpineagents import tool
@tool(read_only=True, open_world=False)
def read_file(path: str) -> str:
"""Read a file in the project"""
return Path(path).read_text()
| Hint | The tool | Left out |
|---|---|---|
read_only |
Changes nothing | False |
destructive |
May delete or overwrite something | True, or False for a read-only tool |
idempotent |
Has no further effect when called again with the same arguments | False, or True for a read-only tool |
open_world |
Reaches outside its own domain: the web, a shell, another system | True |
A hint left out assumes the worst, so a forgotten hint makes a rule stricter, never looser. The hints have the meaning of MCP tool annotations.
Decide by what one call does¶
The hints of @tool hold for every call, so a shell tool is never read-only: it can run rm. With hints_for=,
the tool says what one call does, and a permission reads that:
import subprocess
from alpineagents import Agent, Hints, MCPTool, tool
from alpineagents.permissions import AllowPermission, Allowed, DecideByHuman
LOOK = {"ls", "pwd", "cat", "grep"} # change nothing
ADD = {"mkdir", "touch"} # create things, never delete or overwrite
def bash_hints(args: dict) -> Hints | None:
command = args.get("command")
if not isinstance(command, str) or any(c in command for c in ";&|<>`$\n"):
return None # not one simple command: the tool's own hints
program = command.split()[0] if command.split() else ""
if program in LOOK:
return Hints(read_only=True, open_world=False)
if program in ADD:
return Hints(destructive=False, open_world=False)
return None
@tool(open_world=False, hints_for=bash_hints)
def bash(command: str) -> str:
"""Run a shell command in the project folder"""
done = subprocess.run(command, shell=True, capture_output=True, text=True)
return done.stdout + done.stderr
class AllowNotDestructive(AllowPermission):
"""Allows a call the tool says deletes and overwrites nothing."""
def check(self, state, call, tool):
if isinstance(tool, MCPTool):
return None # an MCP server describes its own tools: do not believe it
return Allowed() if not tool.hints_for(call.args).destructive else None
agent = Agent(
model="claude-sonnet-5",
tools=[bash],
permissions=[AllowNotDestructive(), DecideByHuman()],
)
bash_hintssayslsandcatchange nothing, andmkdirandtouchdelete and overwrite nothing. For anything else, including commands joined with;or|, it returnsNone, and the tool's own hints (the worst case) apply.AllowNotDestructiveallows the calls whose hints saydestructive=False.DecideByHuman()asks about the rest, such asrm -rf build.-
AllowByReadOnly()readshints_forthe same way, so with itlsruns without asking andmkdirasks. -
Hints(...)takes the same four hints with the same rules: one left out assumes the worst, andread_only=Truesets the other two. - The function gets the model's arguments before they are checked: a value can be missing or of any type. Read with
args.get(...)andisinstance, and returnNonewhen unsure. - Keep hints about facts ("this call deletes nothing") and decisions in permissions ("run what deletes nothing").
tool.copy(hints_for=...)adds or replaces the function. AToolsubclass overrideshints_for(self, args).- To read a call's hints in your own code, look the tool up:
agent.tool_map.get(call.name).
MCP tools¶
An MCP server's tools are named {server}__{tool}, where server is the name= you gave MCP(...). The
names are yours, so DenyByName(["github__delete_*"]) and AllowByName(["github__get_*"]) work.
The hints of an MCP tool come from the server, which describes its own tools. AllowByReadOnly() does not believe
them: it has no opinion about an MCP tool, and the next permission decides. AllowByReadOnly(trust_mcp=True) believes
the server's readOnlyHint. Do this only for a server you trust as much as your own code. A permission of your own
that reads hints checks isinstance(tool, MCPTool) the same way, as AllowNotDestructive above does.
Write your own permission¶
Subclass one of three kinds, and implement check(self, state, call, tool):
| Kind | check returns |
|---|---|
DenyPermission |
Denied(reason) or None. Asked first, wherever it is in the list |
AllowPermission |
Allowed() or None |
DecidePermission |
Allowed(), Denied(reason) or None |
None means no opinion: the next permission decides. This one refuses paths outside a folder:
from pathlib import Path
from alpineagents import Agent, tool
from alpineagents.permissions import AllowByReadOnly, DecideByHuman, Denied, DenyPermission
@tool(read_only=True, open_world=False)
def read_file(path: str) -> str:
"""Read a file"""
return Path(path).read_text()
@tool(open_world=False)
def write_file(path: str, content: str) -> None:
"""Create a file, or replace its content"""
Path(path).write_text(content)
class DenyOutsideFolder(DenyPermission):
"""Refuses a call whose path= argument is outside a folder."""
def __init__(self, folder):
self.folder = Path(folder).resolve()
def check(self, state, call, tool):
path = call.args.get("path")
if path is None:
return None # no path: no opinion
if not isinstance(path, str) or not (self.folder / path).resolve().is_relative_to(self.folder):
return Denied(f"{path!r} is outside {self.folder}. Use a path inside it.")
return None
def __repr__(self):
return f"DenyOutsideFolder({str(self.folder)!r})"
agent = Agent(
model="claude-sonnet-5",
tools=[read_file, write_file],
permissions=[DenyOutsideFolder("."), AllowByReadOnly(), DecideByHuman()],
)
print(agent.run("Read notes.md and write a summary next to it"))
call.argsare the model's arguments before they are checked against the tool's parameters: any value may be missing or have the wrong type. Read them with.get(...)andisinstance.toolis theToolthe call would run.call.nameis the name the model called.repr()of the permission is whatoutcome.decided_byandStoppedByPermission.permissionshow. Give a permission with settings a__repr__that shows them.- A verdict the kind may not give, such as
Allowed()from aDenyPermission, raisesTypeErrorduring the run.
Add "always"¶
Let the person approve a tool once for the rest of the task:
from pathlib import Path
from typing import Literal
from alpineagents import Agent, Terminal, tool
from alpineagents.permissions import AllowByReadOnly, Allowed, DecidePermission, Denied
from alpineagents.types import format_call
@tool(read_only=True, open_world=False)
def read_file(path: str) -> str:
"""Read a file"""
return Path(path).read_text()
@tool(open_world=False)
def write_file(path: str, content: str) -> None:
"""Create a file, or replace its content"""
Path(path).write_text(content)
class AskWithAlways(DecidePermission):
"""Asks before each call. "always" allows the tool for the rest of the task."""
def __init__(self, human):
self.human = human
def check(self, state, call, tool):
if call.name in state.extra_data.get("always", []):
return Allowed()
choices = Literal["yes", "no", "always"]
answer = self.human.ask(state, f"Run {format_call(call)}?", choices)
if answer == "no":
return Denied("The user declined this call. Wait for their next message.", stop=True)
if answer == "always":
with state.edit_extra_data() as data:
data.setdefault("always", []).append(call.name)
return Allowed()
agent = Agent(
model="claude-sonnet-5",
tools=[read_file, write_file],
permissions=[AllowByReadOnly(), AskWithAlways(Terminal())],
)
print(agent.run("Write a short README and a CONTRIBUTING file for this folder"))
Denied(reason, stop=True)also stops the run, likeDecideByHuman'sno.state.extra_data["always"]keeps the approved tool names across turns and runs, and a store saves it.edit_extra_data()records the change as one history entry. The model never seesextra_data.self.human.ask(...)asks directly, so the question is not recorded instate.historythe wayDecideByHumanrecords it.
When a check fails¶
- Raise
ToolError(message)when the check itself fails in a way the model should hear about, for example the approval service did not answer. The call does not run, the model getsmessageas its error result, and the run continues. The outcome is"denied", with theToolErrorasoutcome.error. - Any other exception propagates out of
use_toolsandrun, like one raised by a tool. The call does not run. - A failure never counts as allowed.
Async permissions¶
A permission that awaits something, such as an approval service over HTTP, implements async def acheck(self, state,
call, tool) instead of check, with the same verdicts. arun and ause_tools await it. A sync run cannot, so it
raises TypeError when the run starts, before the first model call. A permission with only check works with
arun too: it runs on a worker thread. DecideByHuman implements both. A DecideByHuman subclass that overrides
only acheck counts as async-only: a sync run raises TypeError rather than ask the Human. One that overrides only
check decides the same way in arun.
Permissions in subagents¶
permissions= applies to every tool call in the run, including the calls inside its subagents.
You write the list once, on the Agent you run, and a subagent cannot get around it. The code below has two subagents:
from pathlib import Path
from alpineagents import Agent, tool
from alpineagents.permissions import AllowByName, AllowByReadOnly, DecideByHuman, DenyByName
@tool(read_only=True)
def read_file(path: str) -> str:
"""Read a text file"""
return Path(path).read_text()
@tool
def write_file(path: str, content: str) -> None:
"""Create a file, or replace its content"""
Path(path).write_text(content)
@tool
def delete_file(path: str) -> None:
"""Delete a file"""
Path(path).unlink()
# No permissions= of its own: the lead's whole list decides its calls
editor = Agent(
model="claude-sonnet-5",
name="editor",
description="Edits the files of the project",
tools=[read_file, write_file, delete_file],
)
# A list of its own: it replaces the lead's allow and decide rules
drafter = Agent(
model="claude-sonnet-5",
name="drafter",
description="Writes drafts into the drafts folder",
tools=[write_file, delete_file],
permissions=[AllowByName(["write_file"])],
)
lead = Agent(
model="claude-sonnet-5",
tools=[read_file, editor, drafter],
permissions=[
DenyByName(["delete_*"], reason="Nothing is deleted in this run."),
AllowByName(["editor", "drafter"]),
AllowByReadOnly(),
DecideByHuman(),
],
)
editorhas nopermissions=, so the whole list ofleadchecks its calls.delete_*is refused, andwrite_filegoes toDecideByHuman(), which asks the person oflead.drafterhas a list of its own. It decides fordrafter's calls instead of the allow and decide rules oflead:write_fileis allowed without asking.delete_*is still refused for both. A subagent cannot widen aDenyPermissionof the runs above it.AllowByName(["editor", "drafter"])letsleadcall the subagents. A subagent's tool does not say it is read-only, soAllowByReadOnly()does not allow it.
The rule for the calls of one subagent:
The subagent's permissions= |
Which permissions check its calls |
|---|---|
None (left out) |
The whole list of the run above it |
| A list | The DenyPermissions of every run above it, from the top, then its own list |
So:
- Put what must always hold in
DenyBy...permissions, such asDenyByName(["delete_*"]). They reach every subagent and every subagent of a subagent. - A list of allow rules does not bind a subagent with a list of its own.
[AllowByName(["read_file"])]on the lead means "refuse the rest" for the lead's own calls. A subagent with its own list is checked by that list, so a call that list allows is not refused by the lead's "refuse the rest". To refuse it everywhere, use aDenyPermission. - A subagent with
permissions=[]has every call refused (with aPermissionWarning), after theDenyPermissions above it. - A permission asks the person of the Agent that wrote it.
DecideByHuman()in the lead's list asks the lead'shumanabout a call the editor makes. ADecideByHuman()in the subagent's own list asks the subagent'shuman, which is the lead's when the subagent has none of its own (Subagents). - A
DenyPermissionabove also binds other Agents that run tools inside a subagent. If a subagent's custom loop callsother.use_tools(state)orother.run(...), the denials of the runs above the subagent are asked first, thenother's own list.othercannot be used to get around them. outcome.decided_byin the Reporter names the permission that decided, wherever it was written.- The checks before the run starts apply to the inherited list too: a
DecideByHuman()with no person to ask, or an async-only permission under a syncrun, raises before the first request.
Not a security boundary¶
Permissions keep an honest model from doing what you did not want. They do not stop a determined one:
bash_hintsabove sayscatis read-only, and it is, butcat ~/.ssh/id_rsastill reads a secret.- A command can do more than its first word says, through an alias, a script it runs or a program's own options.
- A tool can do things its hints do not say.
Treat permissions as a way to ask the person at the right moments, not as a way to contain the agent. When a tool runs commands or code the model wrote, run the agent in a sandbox: a container or a virtual machine with only the files, network access and credentials the task needs.
Related¶
- Use subagents
- Show progress and ask the person: answer questions from somewhere other than the terminal
- Stop a run: every way a run stops
- Test your agent: test the approval flow with
FakeHuman - API reference: Permissions