Permission Guards¶
Guards gate tool execution. They sit between parameter validation and handler invocation, forming a composable middleware chain that can allow, deny, or modify tool calls.
For an application that needs project rules and user approval, use PolicyGuard
from axio.permission_policy. It builds on the same guard interface described
below. Tool providers declare the effects of each operation, the policy decides
whether approval is required, and your interface collects the user’s answer.
Declare what a tool will do¶
A tool name or description cannot tell a permission system which resources a
call touches. The provider supplies Tool(access=...): a function of the input
arguments returning Access values. A file writer, for example, declares
Access("write", filename). The declaration must match the handler’s behavior.
Access supports read, write, execute, external, and unknown.
file_access(operation, parameter) builds a descriptor for one local path
argument. Multiple effects require a descriptor that returns all of them.
Recursive tools must also declare any symlink targets they can follow.
An absent descriptor means unknown effects. An explicit empty tuple declares that the handler has no external effects. Only use it for a tool that really has none. Descriptors report effects without executing the tool.
Apply project permissions¶
PolicyGuard automatically allows declared reads inside its project root.
Writes, reads outside the root, execution, and opaque calls need a matching
rule or an approval. Explicit denials take priority over allows and automatic
project reads. A grant to read a file does not grant permission to write it.
Here is a complete example with a simulated user approving a single write.
Replace approve_once() with your UI or console prompt in an application:
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from axio import Tool
from axio.permission_policy import Approval, PermissionRequest, PolicyGuard
from axio.tool_access import file_access
async def read_note(filename: str) -> str:
"""Read a note."""
return Path(filename).read_text()
async def write_note(filename: str, text: str) -> str:
"""Replace a note's contents."""
Path(filename).write_text(text)
return "Written"
async def main() -> None:
requests = []
async def approve_once(request: PermissionRequest) -> Approval:
requests.append(request)
# Stand-in for a user accepting the displayed operation once.
print(request.describe_scope())
return Approval(decision="allow", lifetime="once")
with TemporaryDirectory() as directory:
root = Path(directory)
note = root / "notes.txt"
note.write_text("Draft")
policy = PolicyGuard(project_root=root, prompt=approve_once)
policy.set_session("chat-1")
read = Tool(name="read_note", handler=read_note, access=file_access("read", "filename"), guards=(policy,))
write = Tool(name="write_note", handler=write_note, access=file_access("write", "filename"), guards=(policy,))
assert await read(filename=str(note)) == "Draft"
assert requests == []
await write(filename=str(note), text="Reviewed")
assert len(requests) == 1
assert note.read_text() == "Reviewed"
asyncio.run(main())
To cover a whole agent, pass tool_guards=(policy,) to Agent instead of
attaching the policy to each tool. It then covers provider tools, dynamically
loaded instruction tools, and load_instruction itself. Application guards
run after each tool’s original guards, so they see the final arguments.
Individual entry-point handlers can use
@tool_access(file_access("read", "filename")) from axio.tool_access to attach
a descriptor. The resulting Tool preserves it. This lets a third-party tool
participate in both permission checks and scoped instruction preflight.
Choose approval scope and lifetime¶
An approval answers the displayed PermissionRequest. Your prompt returns an
Approval with decision="allow" or "deny" and one lifetime:
Lifetime |
Effect |
|---|---|
|
Applies to this call only; no rule is stored |
|
Reused in the current conversation |
|
Saved through the application’s |
Call policy.set_session(id) when switching, restoring, or forking a chat.
Changing the session ID clears chat rules. Project approvals require a supplied
store; without one, saving a project rule is an error. policy.rules() lists
active rules, and policy.revoke(rule_id) removes a rule. The TUI supplies its
own store and permission dialog; /permissions exposes these operations.
File rules cover the displayed operations and paths. directories=True
explicitly extends a chat or project rule to their parent directories and
descendants. For shell, Python, remote, and other opaque operations, approvals
cover the exact tool, arguments, working directory, and provider identity.
They are not command-prefix rules. Tool.permission_identity lets a provider
distinguish changed server configurations so old approvals cannot carry over.
Concurrent permission prompts are serialized and recheck rules before asking. An optional advisor can add an LLM review to the prompt, but its recommendation cannot replace approval or override an explicit denial. A provider’s effect declaration is a contract; it cannot prevent a handler lying about its effects. Use an operating-system or container sandbox when filesystem isolation is required.
PermissionGuard ABC¶
from abc import ABC, abstractmethod
from typing import Any
from axio import Tool
class PermissionGuard(ABC):
async def __call__(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
return await self.check(tool, **kwargs)
@abstractmethod
async def check(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]: ...
A guard receives the Tool object and the raw keyword arguments, and must either:
Return a
dictof (possibly modified) kwargs to allow execution.Raise
GuardErrorto deny execution.
Guard chain¶
When a Tool has multiple guards, they run sequentially. Each guard’s
output kwargs become the next guard’s input:
import asyncio
from typing import Any
from axio.permission import AllowAllGuard
from axio import Tool
async def echo(text: str) -> str:
"""Echo text."""
return text
_tool: Tool[Any] = Tool(name="echo", handler=echo)
async def main():
guards = (AllowAllGuard(),)
kwargs: dict[str, Any] = {"text": "hello"}
for guard in guards:
kwargs = await guard(_tool, **kwargs)
assert kwargs["text"] == "hello"
asyncio.run(main())
This lets you compose guards freely. For example, a path-validation guard followed by an LLM-based risk-assessment guard:
from axio import Tool
tool = Tool(
name="write_file",
handler=write_file,
guards=(PathGuard(), LLMGuard()),
)
assert tool.name == "write_file"
assert len(tool.guards) == 2
ConcurrentGuard¶
For guards that need concurrency control (e.g., rate-limiting or guards that
call external services), subclass ConcurrentGuard:
import asyncio
from abc import ABC, abstractmethod
from typing import Any
from axio import PermissionGuard, Tool
class ConcurrentGuard(PermissionGuard, ABC):
concurrency: int = 1
def __init__(self) -> None:
self._semaphore = asyncio.Semaphore(self.concurrency)
async def __call__(self, tool: Tool[Any], **kwargs: Any) -> dict[str, Any]:
async with self._semaphore:
return await self.check(tool, **kwargs)
Set the concurrency class variable to control how many tool calls can
pass through the guard simultaneously.
Built-in guards¶
AllowAllGuardAlways returns kwargs unchanged. Useful as a default.
DenyAllGuardAlways raises
GuardError("denied"). Useful for testing or disabling tools.
See Writing Guards for a step-by-step guide to creating your own.