Tool Permissions
Agent Air includes a permission system that protects sensitive operations. Tools can declare required permissions, and users can grant permissions for single operations or entire sessions.
Permission Model
The permission model defines what resources a tool wants to access and at what level. By structuring permissions this way, the framework can check existing grants, aggregate requests, and cache approvals for similar future requests.
Permissions are defined by three components:
| Component | Description |
|---|---|
| Target Type | What kind of resource: path, domain, or command |
| Target | The specific resource (file path, domain name, command) |
| Level | Access level: read, write, execute, or admin |
Permission Request
The PermissionRequest struct captures all the information needed to prompt the user for approval. Include a clear description and reason to help users make informed decisions.
pub struct PermissionRequest {
pub target_type: TargetType,
pub target: String,
pub level: PermissionLevel,
pub description: String,
pub reason: Option<String>,
pub recursive: bool,
}
Target Types
Different operations require different types of targets. File operations use paths, network operations use domains, and shell operations use command names.
| Type | Description | Example |
|---|---|---|
Path | File system paths | /home/user/project/config.yaml |
Domain | Network domains | api.example.com |
Command | Shell commands | rm, docker, sudo |
Permission Levels
Permission levels indicate the type of access needed. Higher levels imply greater potential impact and may require more explicit user approval.
| Level | Description |
|---|---|
Read | Read-only access |
Write | Modify or create |
Execute | Run commands or scripts |
Admin | Full control, dangerous operations |
Declaring Required Permissions
Tools declare what permissions they need by implementing the required_permissions() method. The framework calls this before execution to determine what approvals are needed. Base permissions on the actual input parameters to request only what’s necessary.
Tools declare permissions via required_permissions():
fn required_permissions(
&self,
input: &HashMap<String, serde_json::Value>,
) -> Vec<PermissionRequest> {
let file_path = input.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("");
vec![PermissionRequest {
target_type: TargetType::Path,
target: file_path.to_string(),
level: PermissionLevel::Write,
description: format!("Write to {}", file_path),
reason: Some("Save configuration changes".to_string()),
recursive: false,
}]
}
Recursive Permissions
When a tool operates on entire directories, set the recursive flag to cover all contained files. This avoids repeated prompts for each file in a directory.
For directory operations, set recursive: true:
PermissionRequest {
target_type: TargetType::Path,
target: "/home/user/project".to_string(),
level: PermissionLevel::Write,
recursive: true, // Covers all files in directory
..Default::default()
}
Multiple Permissions
Complex tools may need multiple permissions for different resources. Return all needed permissions so the framework can present them together.
Return multiple requests when needed:
fn required_permissions(
&self,
input: &HashMap<String, serde_json::Value>,
) -> Vec<PermissionRequest> {
vec![
PermissionRequest {
target_type: TargetType::Path,
target: "/etc/config".to_string(),
level: PermissionLevel::Read,
description: "Read system configuration".to_string(),
..Default::default()
},
PermissionRequest {
target_type: TargetType::Domain,
target: "api.service.com".to_string(),
level: PermissionLevel::Execute,
description: "Call external API".to_string(),
..Default::default()
},
]
}
Permission Scopes
Permission scope determines how long an approval lasts. Session scope reduces friction for repeated operations while maintaining user control over what the agent can do.
Users can grant permissions with two scopes:
| Scope | Description |
|---|---|
Once | Single operation only |
Session | All similar requests for the rest of the session |
Session Scope
Session-scoped permissions persist until the session ends. They’re ideal for iterative workflows where the user trusts the agent to perform similar operations repeatedly.
When a user grants “session” scope, subsequent requests of the same type are automatically approved:
// First request: User sees prompt, grants session permission
// Permission: Write to /home/user/project/*
// Later requests: Automatically approved
// Write to /home/user/project/src/main.rs ✓
// Write to /home/user/project/Cargo.toml ✓
Once Scope
Once scope provides maximum control, requiring approval for every operation. This is appropriate for one-off operations or when users want to review each action individually.
Each operation requires explicit approval:
// Request 1: User grants "once"
// Write to config.yaml ✓
// Request 2: User prompted again
// Write to settings.yaml ?
Permission Registry
The permission registry is the central component for managing permission state. It tracks pending requests, caches session-level grants, and coordinates between tools and the UI to present permission prompts to users.
The PermissionRegistry manages permission requests and caches session grants.
Checking Existing Permissions
Before requesting a new permission, check if it’s already been granted for this session. This avoids unnecessary prompts and improves the user experience.
Before prompting, check if permission is already granted:
if registry.is_granted(session_id, &request).await {
// Permission already granted for this session
// Proceed without prompting
}
Registering a Request
When a tool needs permission that hasn’t been granted, it registers the request with the registry. The registry returns a channel that the tool awaits until the user responds.
When permission is needed:
let rx = registry.register(
context.tool_use_id,
context.session_id,
request,
context.turn_id,
).await?;
// Wait for user response
let response = rx.await?;
if response.granted {
// Proceed with operation
} else {
// Handle denial
}
Permission Response
The response includes whether permission was granted and, if so, at what scope. Tools should handle both granted and denied cases appropriately.
pub struct PermissionResponse {
pub granted: bool,
pub scope: Option<PermissionScope>,
pub message: Option<String>,
}
Batch Permission Handling
When the LLM calls multiple tools at once, the framework aggregates all required permissions into a single prompt. This provides a better user experience than interrupting with multiple sequential prompts.
When multiple tools execute in a batch, permissions are aggregated.
Aggregation
// Tool 1: Write to /path/a
// Tool 2: Write to /path/b
// Tool 3: Read from /path/c
// User sees single prompt:
// "Grant the following permissions?"
// - Write to /path/a
// - Write to /path/b
// - Read from /path/c
Partial Grants
Users don’t have to approve everything. They can grant some permissions and deny others, and the framework handles running only the tools with approved permissions.
Users can grant some permissions and deny others:
// User grants:
// - Write to /path/a ✓
// - Write to /path/b ✗ (denied)
// - Read from /path/c ✓
// Result:
// Tool 1: Executes
// Tool 2: Fails with "Permission denied"
// Tool 3: Executes
Session Grants in Batch
Session-scoped grants made during batch execution apply to all future operations in that session, including subsequent batches. This allows efficient workflows without repeated prompts.
Session-scoped grants apply to future batch operations:
// Batch 1: User grants session permission for writes to /project/*
// Batch 2: Contains write to /project/src/lib.rs
// - Automatically approved, no prompt
Self-Managed Permissions
For advanced use cases, tools can bypass the automatic permission system and manage permissions directly. This is useful when permission requirements depend on runtime conditions or when custom permission UI is needed.
Some tools manage their own permission flow using ask_for_permissions.
When to Self-Manage
- Complex permission logic based on runtime conditions
- Custom permission UI requirements
- Multi-step operations with conditional permissions
Implementation
fn handles_own_permissions(&self) -> bool {
true // Framework won't call required_permissions()
}
fn execute(
&self,
context: ToolContext,
input: HashMap<String, serde_json::Value>,
) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
let registry = self.permission_registry.clone();
Box::pin(async move {
// Check permission manually
let request = build_permission_request(&input);
if !registry.is_granted(context.session_id, &request).await {
let rx = registry.register(
context.tool_use_id,
context.session_id,
request,
context.turn_id,
).await?;
let response = rx.await?;
if !response.granted {
return Err("Permission denied".to_string());
}
}
// Perform operation
Ok("Success".to_string())
})
}
Built-in Tool Permissions
The standard tools included with agent-air have appropriate permission requirements pre-configured. Understanding these helps when deciding what permissions your custom tools need.
Standard tools have pre-configured permission requirements:
| Tool | Permission |
|---|---|
read_file | Read on file path |
write_file | Write on file path |
edit_file | Write on file path |
multi_edit | Write on file path |
ls | Read on directory |
glob | Read on search path |
grep | Read on search path |
bash | Execute on command |
Permission Caching
Built-in tools leverage session caching so users don’t need to repeatedly approve similar operations. Once granted, permissions for a path pattern apply to all matching files.
Write tools cache session permissions:
// First write to /project/src/main.rs
// User grants session permission for /project/*
// Subsequent writes in /project/* proceed without prompts
Dangerous Command Detection
The bash tool includes special handling for potentially destructive commands. These commands are detected and require explicit admin-level approval with clear warnings about their impact.
The bash tool includes dangerous command detection:
// Detected dangerous patterns:
// - rm -rf /
// - :(){ :|:& };: (fork bomb)
// - dd if=/dev/zero of=/dev/sda
// - chmod -R 777 /
These commands require explicit admin-level permission with clear warnings.
User Experience
The permission system is designed to be informative and non-intrusive. Users see clear descriptions of what the agent wants to do and can make informed decisions about granting access.
Permission Prompt
When permissions are required, the user sees:
Permission Request
The assistant wants to:
- Write to /home/user/project/config.yaml
Reason: Save database configuration
[Grant Once] [Grant for Session] [Deny]
Session Grant Notice
After granting session permission:
Permission granted for session.
Future write operations to /home/user/project/* will proceed without prompting.
Denial Handling
When denied, the LLM receives:
{
"granted": false,
"message": "User declined write permission"
}
The LLM can then explain the limitation or suggest alternatives.
Best Practices
Following these best practices helps create tools that users trust and reduces friction during operation. Good permission design balances security with usability.
Request Minimal Permissions
Only request what’s needed:
// Good: Specific path
PermissionRequest {
target: "/project/config.yaml".to_string(),
level: PermissionLevel::Write,
recursive: false,
..
}
// Avoid: Broad path with recursive
PermissionRequest {
target: "/".to_string(),
level: PermissionLevel::Write,
recursive: true, // Too broad
..
}
Provide Clear Descriptions
Clear descriptions help users understand what the tool wants to do and why. Include both what action will be taken and the reason it’s needed.
Help users understand why permission is needed:
PermissionRequest {
description: "Write updated API configuration".to_string(),
reason: Some("Apply the rate limit changes you requested".to_string()),
..
}
Handle Denials Gracefully
When users deny permission, respond constructively. The LLM can use the error message to explain what couldn’t be done and suggest alternatives.
Return informative errors when permissions are denied:
if !response.granted {
return Err(format!(
"Permission denied: Cannot write to {}. \
The configuration changes were not saved.",
file_path
));
} 