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:

ComponentDescription
Target TypeWhat kind of resource: path, domain, or command
TargetThe specific resource (file path, domain name, command)
LevelAccess 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.

TypeDescriptionExample
PathFile system paths/home/user/project/config.yaml
DomainNetwork domainsapi.example.com
CommandShell commandsrm, 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.

LevelDescription
ReadRead-only access
WriteModify or create
ExecuteRun commands or scripts
AdminFull 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:

ScopeDescription
OnceSingle operation only
SessionAll 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:

ToolPermission
read_fileRead on file path
write_fileWrite on file path
edit_fileWrite on file path
multi_editWrite on file path
lsRead on directory
globRead on search path
grepRead on search path
bashExecute 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
    ));
}