Permission Policies

The PermissionPolicy trait enables automated permission handling for server environments. When tools request permission to perform sensitive operations, the policy decides whether to approve, deny, or escalate to a user. This automation is essential for headless agents that can’t display interactive permission prompts.

Policies encapsulate your security decisions in reusable components. Rather than handling each permission request individually, you define rules that the agent applies consistently. This approach scales well for server deployments where you need predictable, auditable permission behavior.


The PermissionPolicy Trait

The trait has a single method that examines a permission request and returns a decision. The agent calls this method before forwarding permission events to the EventSink.

pub trait PermissionPolicy: Send + Sync {
    /// Decide how to handle a permission request
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision;
}

The method receives full details about what the tool wants to do, enabling sophisticated decision logic based on the operation type, target resource, and other context.


PolicyDecision

The decision enum has three variants covering the possible outcomes.

pub enum PolicyDecision {
    /// Approve the request immediately
    Allow,

    /// Forward to the EventSink for user decision
    AskUser,

    /// Deny the request with a reason
    Deny { reason: String },
}

Allow

Approve the request without user interaction. The tool proceeds immediately. Use this for operations you’ve determined are safe in your environment.

AskUser

Forward the request to the EventSink as a PermissionRequired event. Your application must then collect a decision from somewhere—a real user, an external authorization system, or fallback logic—and respond through the PermissionRegistry.

Deny

Reject the request with an explanation. The tool receives an error, and the LLM can decide how to proceed (try a different approach, ask the user, or report the limitation).


Built-in Policies

Agent Air provides three built-in policies for common scenarios.

AutoApprovePolicy

Approves all permission requests unconditionally. Use this in trusted environments where the agent has full access to perform any operation.

use agent_air::policy::AutoApprovePolicy;

let policy = AutoApprovePolicy::new();

// All requests return PolicyDecision::Allow

Appropriate for:

  • Development and testing environments
  • Internal automation where the agent is fully trusted
  • Sandboxed containers with limited blast radius

DenyAllPolicy

Denies all permission requests. Use this for read-only agents or sandboxed execution where tools should never perform sensitive operations.

use agent_air::policy::DenyAllPolicy;

let policy = DenyAllPolicy::new();

// All requests return PolicyDecision::Deny

Appropriate for:

  • Read-only agents that only analyze data
  • Demo environments where writes are disabled
  • Security-sensitive deployments with strict controls

InteractivePolicy

Always asks the user by returning AskUser. Use this when you have a mechanism to collect user decisions, such as forwarding prompts to a chat interface.

use agent_air::policy::InteractivePolicy;

let policy = InteractivePolicy::new();

// All requests return PolicyDecision::AskUser

Appropriate for:

  • Chat applications where users can respond to prompts
  • Approval workflows where humans review each action
  • Hybrid systems with some automated and some manual decisions

Implementing Custom Policies

Create custom policies by implementing the PermissionPolicy trait. Your implementation can use any logic to make decisions—operation type, resource path, time of day, external authorization services, or combinations thereof.

Path-Based Policy

A policy that approves operations on allowed paths and denies others:

use agent_air::{PermissionPolicy, PermissionRequest, PolicyDecision};

pub struct PathBasedPolicy {
    allowed_paths: Vec<String>,
    allowed_commands: Vec<String>,
}

impl PathBasedPolicy {
    pub fn new(allowed_paths: Vec<String>, allowed_commands: Vec<String>) -> Self {
        Self { allowed_paths, allowed_commands }
    }

    fn is_path_allowed(&self, path: &str) -> bool {
        self.allowed_paths.iter().any(|allowed| path.starts_with(allowed))
    }

    fn is_command_allowed(&self, command: &str) -> bool {
        self.allowed_commands.iter().any(|allowed| command.starts_with(allowed))
    }
}

impl PermissionPolicy for PathBasedPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        match request.target_type {
            TargetType::Path => {
                if self.is_path_allowed(&request.target) {
                    PolicyDecision::Allow
                } else {
                    PolicyDecision::Deny {
                        reason: format!("Path '{}' is not in allowed list", request.target)
                    }
                }
            }
            TargetType::Command => {
                if self.is_command_allowed(&request.target) {
                    PolicyDecision::Allow
                } else {
                    PolicyDecision::Deny {
                        reason: format!("Command '{}' is not allowed", request.target)
                    }
                }
            }
            TargetType::Domain => PolicyDecision::Allow,  // Allow all network access
        }
    }
}

This policy creates a whitelist of safe paths and commands while allowing network access.

Level-Based Policy

A policy that makes decisions based on the permission level:

use agent_air::{PermissionPolicy, PermissionRequest, PolicyDecision, PermissionLevel};

pub struct LevelBasedPolicy {
    max_auto_approve_level: PermissionLevel,
}

impl LevelBasedPolicy {
    pub fn read_only() -> Self {
        Self { max_auto_approve_level: PermissionLevel::Read }
    }

    pub fn read_write() -> Self {
        Self { max_auto_approve_level: PermissionLevel::Write }
    }
}

impl PermissionPolicy for LevelBasedPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        if request.level <= self.max_auto_approve_level {
            PolicyDecision::Allow
        } else {
            PolicyDecision::Deny {
                reason: format!(
                    "Operation requires {:?} permission, but only {:?} is auto-approved",
                    request.level, self.max_auto_approve_level
                )
            }
        }
    }
}

This policy auto-approves low-risk operations while blocking higher-risk ones.

External Authorization Policy

A policy that checks with an external authorization service:

use agent_air::{PermissionPolicy, PermissionRequest, PolicyDecision};

pub struct ExternalAuthPolicy {
    auth_client: AuthClient,
    user_id: String,
}

impl PermissionPolicy for ExternalAuthPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        // Note: This blocks, which is not ideal. Consider caching or async alternatives.
        match self.auth_client.check_permission_sync(&self.user_id, request) {
            Ok(true) => PolicyDecision::Allow,
            Ok(false) => PolicyDecision::Deny {
                reason: "Authorization denied by policy server".to_string()
            },
            Err(e) => {
                log::error!("Auth check failed: {}", e);
                PolicyDecision::Deny {
                    reason: "Authorization service unavailable".to_string()
                }
            }
        }
    }
}

Integrate with your existing authorization infrastructure for consistent access control across your systems.


Combining Policies

Create composite policies that combine multiple decision strategies.

Chain Policy

Try policies in order until one makes a definitive decision:

pub struct ChainPolicy {
    policies: Vec<Box<dyn PermissionPolicy>>,
    default: PolicyDecision,
}

impl PermissionPolicy for ChainPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        for policy in &self.policies {
            match policy.decide(request) {
                PolicyDecision::Allow => return PolicyDecision::Allow,
                PolicyDecision::Deny { reason } => return PolicyDecision::Deny { reason },
                PolicyDecision::AskUser => continue,  // Try next policy
            }
        }
        self.default.clone()
    }
}

// Usage: Try path-based first, then level-based, default to deny
let policy = ChainPolicy {
    policies: vec![
        Box::new(PathBasedPolicy::new(allowed_paths, allowed_commands)),
        Box::new(LevelBasedPolicy::read_write()),
    ],
    default: PolicyDecision::Deny { reason: "No policy approved".to_string() },
};

Override Policy

Apply overrides for specific cases on top of a base policy:

pub struct OverridePolicy {
    base: Box<dyn PermissionPolicy>,
    overrides: HashMap<String, PolicyDecision>,
}

impl PermissionPolicy for OverridePolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        // Check overrides first
        if let Some(decision) = self.overrides.get(&request.target) {
            return decision.clone();
        }

        // Fall back to base policy
        self.base.decide(request)
    }
}

Policy Context

The PermissionRequest provides context for making decisions:

pub struct PermissionRequest {
    /// What kind of resource: Path, Domain, or Command
    pub target_type: TargetType,

    /// The specific resource (file path, domain name, command)
    pub target: String,

    /// Access level: Read, Write, Execute, or Admin
    pub level: PermissionLevel,

    /// Human-readable description of the operation
    pub description: String,

    /// Optional reason why the tool needs this permission
    pub reason: Option<String>,

    /// Whether this covers a directory recursively
    pub recursive: bool,
}

Use these fields to make nuanced decisions:

impl PermissionPolicy for NuancedPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        // Be more cautious with recursive operations
        if request.recursive && request.level >= PermissionLevel::Write {
            return PolicyDecision::AskUser;
        }

        // Allow reads to documentation directories
        if request.level == PermissionLevel::Read
            && request.target.contains("/docs/") {
            return PolicyDecision::Allow;
        }

        // Deny admin-level operations
        if request.level == PermissionLevel::Admin {
            return PolicyDecision::Deny {
                reason: "Admin operations require manual approval".to_string()
            };
        }

        PolicyDecision::AskUser
    }
}

Logging and Auditing

Log policy decisions for security auditing and debugging:

pub struct AuditingPolicy {
    inner: Box<dyn PermissionPolicy>,
    logger: Logger,
}

impl PermissionPolicy for AuditingPolicy {
    fn decide(&self, request: &PermissionRequest) -> PolicyDecision {
        let decision = self.inner.decide(request);

        self.logger.log(AuditEntry {
            timestamp: Utc::now(),
            target_type: request.target_type,
            target: request.target.clone(),
            level: request.level,
            decision: decision.clone(),
        });

        decision
    }
}

Audit logs help with compliance requirements and incident investigation.


Testing Policies

Test your policies with various request scenarios:

#[test]
fn test_path_based_policy() {
    let policy = PathBasedPolicy::new(
        vec!["/home/user/projects".to_string()],
        vec!["git".to_string(), "npm".to_string()],
    );

    // Should allow project paths
    let allowed_request = PermissionRequest {
        target_type: TargetType::Path,
        target: "/home/user/projects/myapp/src/main.rs".to_string(),
        level: PermissionLevel::Write,
        ..Default::default()
    };
    assert!(matches!(policy.decide(&allowed_request), PolicyDecision::Allow));

    // Should deny system paths
    let denied_request = PermissionRequest {
        target_type: TargetType::Path,
        target: "/etc/passwd".to_string(),
        level: PermissionLevel::Read,
        ..Default::default()
    };
    assert!(matches!(policy.decide(&denied_request), PolicyDecision::Deny { .. }));
}

Cover edge cases like empty targets, unusual permission levels, and boundary conditions.