Permission System¶
ToolRegistry provides a built-in permission system that lets you control which tool calls are allowed, denied, or require explicit confirmation before execution. The system is designed around three concepts: rules, policies, and handlers.
Overview¶
The permission system is evaluated during execute_tool_calls(). When a tool call arrives, the permission policy checks each rule in order (first match wins). Depending on the matched rule's result, the call is either:
- Allowed (
ALLOW) -- the tool executes normally. - Denied (
DENY) -- the tool call is rejected with an error message. - Escalated (
ASK) -- a permission handler is consulted to make the final decision.
If no policy is configured, all tool calls are allowed by default.
Quick Start¶
from toolregistry import (
ToolRegistry,
PermissionPolicy,
PermissionRule,
PermissionResult,
)
registry = ToolRegistry()
# Register some tools
@registry.register
def read_file(path: str) -> str:
"""Read a file from disk."""
return open(path).read()
@registry.register
def delete_file(path: str) -> str:
"""Delete a file from disk."""
import os
os.remove(path)
return f"Deleted {path}"
# Define a permission policy
policy = PermissionPolicy(
rules=[
PermissionRule(
name="allow_read",
match=lambda tool, params: tool.name == "read_file",
result=PermissionResult.ALLOW,
reason="Reading files is safe",
),
PermissionRule(
name="block_delete",
match=lambda tool, params: tool.name == "delete_file",
result=PermissionResult.DENY,
reason="File deletion is not allowed",
),
],
fallback=PermissionResult.DENY,
)
registry.set_permission_policy(policy)
With this policy, read_file calls proceed normally, while delete_file calls are rejected.
Core Concepts¶
PermissionResult¶
A three-state enum representing the outcome of a permission check:
| Value | Description |
|---|---|
ALLOW |
The tool call is permitted |
DENY |
The tool call is rejected |
ASK |
The decision is delegated to a handler |
PermissionRule¶
A single rule that maps a match predicate to a result. Rules are evaluated in order; the first rule whose match returns True determines the outcome.
from toolregistry import PermissionRule, PermissionResult
rule = PermissionRule(
name="ask_for_network_tools",
match=lambda tool, params: "http" in str(params),
result=PermissionResult.ASK,
reason="Tool call involves network access",
)
Attributes:
| Attribute | Type | Description |
|---|---|---|
name |
str |
Human-readable identifier for the rule |
match |
Callable[[Tool, dict], bool] |
Predicate that receives (tool, parameters) |
result |
PermissionResult |
The decision when the rule matches |
reason |
str |
Explanation surfaced in PermissionRequest |
PermissionPolicy¶
An ordered collection of rules with a fallback result.
from toolregistry import PermissionPolicy, PermissionResult
policy = PermissionPolicy(
rules=[rule_1, rule_2, rule_3],
fallback=PermissionResult.DENY, # safe by default
handler=my_handler, # optional policy-level handler
)
Evaluation semantics: Rules are checked in list order. The first rule whose match returns True produces the final decision. If no rule matches, the fallback result is used.
Attributes:
| Attribute | Type | Description |
|---|---|---|
rules |
list[PermissionRule] |
Ordered list of rules |
fallback |
PermissionResult |
Result when no rule matches (default: DENY) |
handler |
PermissionHandler \| None |
Optional policy-level handler for ASK results |
Using Tool Tags with Permissions¶
The permission system works well with ToolTag and ToolMetadata. You can tag tools and write rules that match on tags rather than tool names, making policies more maintainable.
ToolTag¶
Predefined tags for common tool characteristics:
| Tag | Description |
|---|---|
READ_ONLY |
Tool only reads data |
DESTRUCTIVE |
Tool modifies or deletes data |
NETWORK |
Tool requires network access |
FILE_SYSTEM |
Tool accesses the file system |
SLOW |
Tool may take a long time |
PRIVILEGED |
Tool requires elevated permissions |
ToolMetadata Fields¶
Beyond tags, ToolMetadata provides execution hints:
| Field | Type | Default | Description |
|---|---|---|---|
tags |
set[ToolTag] |
set() |
Predefined classification tags |
custom_tags |
set[str] |
set() |
User-defined tags |
timeout |
float \| None |
None |
Per-call timeout in seconds |
is_concurrency_safe |
bool |
True |
Whether the tool can run concurrently |
locality |
"local" \| "remote" \| "any" |
"any" |
Where the tool executes (local machine, remote server, or either) |
Tagging Tools¶
from toolregistry import Tool, ToolMetadata, ToolTag
tool = Tool.from_function(
my_function,
metadata=ToolMetadata(
tags={ToolTag.NETWORK, ToolTag.SLOW},
custom_tags={"requires_api_key"},
timeout=30.0,
locality="remote",
),
)
registry.register(tool)
Built-in Rules¶
ToolRegistry provides pre-built rules that match on ToolTag values:
from toolregistry.permissions.builtin_rules import (
ALLOW_READONLY, # Allows tools tagged READ_ONLY
ASK_DESTRUCTIVE, # Asks for tools tagged DESTRUCTIVE
DENY_PRIVILEGED, # Denies tools tagged PRIVILEGED
ASK_NETWORK, # Asks for tools tagged NETWORK
ASK_FILE_SYSTEM, # Asks for tools tagged FILE_SYSTEM
)
policy = PermissionPolicy(
rules=[
ALLOW_READONLY,
DENY_PRIVILEGED,
ASK_DESTRUCTIVE,
ASK_NETWORK,
ASK_FILE_SYSTEM,
],
fallback=PermissionResult.DENY,
)
registry.set_permission_policy(policy)
Permission Handlers¶
When a rule returns ASK, the system delegates to a permission handler. Handlers implement a simple protocol:
Synchronous Handler¶
from toolregistry import PermissionHandler, PermissionRequest, PermissionResult
class CLIPermissionHandler:
def handle(self, request: PermissionRequest) -> PermissionResult:
print(f"Tool: {request.tool_name}")
print(f"Reason: {request.reason}")
print(f"Parameters: {request.parameters}")
answer = input("Allow this call? [y/N] ")
return PermissionResult.ALLOW if answer.lower() == "y" else PermissionResult.DENY
Asynchronous Handler¶
from toolregistry import AsyncPermissionHandler, PermissionRequest, PermissionResult
class WebSocketPermissionHandler:
async def handle(self, request: PermissionRequest) -> PermissionResult:
response = await ws.ask_user(request.tool_name, request.reason)
return PermissionResult.ALLOW if response == "yes" else PermissionResult.DENY
PermissionRequest¶
The context object passed to handlers when a rule returns ASK:
| Attribute | Type | Description |
|---|---|---|
tool_name |
str |
Name of the tool being invoked |
parameters |
dict[str, Any] |
Arguments the caller intends to pass |
reason |
str |
Explanation from the matched rule |
rule_name |
str |
Name of the rule that triggered ASK |
metadata |
ToolMetadata |
The tool's metadata for handler reference |
Registering Handlers¶
Handlers can be registered at two levels:
-
Registry level -- applies to all policies:
-
Policy level -- takes precedence over registry-level handler:
Resolution order for ASK results:
- Policy-level handler
- Registry-level handler
- Fallback result (defaults to
DENY)
Managing Permissions at Runtime¶
# Set a policy
registry.set_permission_policy(policy)
# Get the current policy
current_policy = registry.get_permission_policy()
# Remove the policy (all calls allowed)
registry.remove_permission_policy()
# Set a handler
registry.set_permission_handler(handler)
# Get the current handler
current_handler = registry.get_permission_handler()
# Remove the handler
registry.remove_permission_handler()
Permission Events¶
The permission system emits change events that can be observed via the callback mechanism:
| Event Type | When |
|---|---|
PERMISSION_DENIED |
A tool call was denied by the policy |
PERMISSION_ASKED |
A tool call was escalated to a handler |
from toolregistry import ChangeEvent, ChangeEventType
def permission_monitor(event: ChangeEvent) -> None:
if event.event_type == ChangeEventType.PERMISSION_DENIED:
print(f"DENIED: {event.tool_name} - {event.reason}")
elif event.event_type == ChangeEventType.PERMISSION_ASKED:
print(f"ASKED: {event.tool_name} - {event.reason}")
registry.on_change(permission_monitor)
Complete Example¶
from toolregistry import (
ToolRegistry,
ToolMetadata,
ToolTag,
PermissionPolicy,
PermissionResult,
PermissionRequest,
)
from toolregistry.permissions.builtin_rules import (
ALLOW_READONLY,
ASK_DESTRUCTIVE,
DENY_PRIVILEGED,
)
registry = ToolRegistry()
# Register tools with metadata
from toolregistry import Tool
def search_db(query: str) -> str:
"""Search the database."""
return f"Results for: {query}"
def drop_table(name: str) -> str:
"""Drop a database table."""
return f"Dropped {name}"
registry.register(
Tool.from_function(
search_db,
metadata=ToolMetadata(tags={ToolTag.READ_ONLY}),
)
)
registry.register(
Tool.from_function(
drop_table,
metadata=ToolMetadata(tags={ToolTag.DESTRUCTIVE}),
)
)
# Create a handler
class SimpleHandler:
def handle(self, request: PermissionRequest) -> PermissionResult:
print(f"[PERMISSION] {request.tool_name}: {request.reason}")
return PermissionResult.DENY # deny by default in this example
# Set up policy and handler
policy = PermissionPolicy(
rules=[ALLOW_READONLY, ASK_DESTRUCTIVE],
fallback=PermissionResult.DENY,
)
registry.set_permission_policy(policy)
registry.set_permission_handler(SimpleHandler())
# search_db will be allowed (READ_ONLY tag matches ALLOW_READONLY)
# drop_table will be escalated to handler (DESTRUCTIVE tag matches ASK_DESTRUCTIVE)
See Also¶
- Permissions API Reference --
PermissionPolicy,PermissionRule,PermissionResult,PermissionHandlerclass details - Execution Modes -- timeout and concurrency-safety settings via
ToolMetadata