use crate::agent::ToolCall;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
FullAccess,
ReadOnly,
WorkspaceWrite,
DenyAll,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub mode: PermissionMode,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub allowlist: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub denylist: Vec<String>,
}
impl Policy {
pub fn full_access() -> Self {
Self {
mode: PermissionMode::FullAccess,
allowlist: vec![],
denylist: vec![],
}
}
pub fn read_only() -> Self {
Self {
mode: PermissionMode::ReadOnly,
allowlist: vec![],
denylist: vec![],
}
}
pub fn workspace_write() -> Self {
Self {
mode: PermissionMode::WorkspaceWrite,
allowlist: vec![],
denylist: vec![],
}
}
pub fn deny_all() -> Self {
Self {
mode: PermissionMode::DenyAll,
allowlist: vec![],
denylist: vec![],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Allow,
Deny,
Ask,
}
pub trait Approver: Send + Sync {
fn approve(&self, tool_call: &ToolCall) -> Decision;
}
pub struct AlwaysAllow;
impl Approver for AlwaysAllow {
fn approve(&self, _call: &ToolCall) -> Decision {
Decision::Allow
}
}
pub struct AlwaysDeny;
impl Approver for AlwaysDeny {
fn approve(&self, _call: &ToolCall) -> Decision {
Decision::Deny
}
}
pub fn authorize(
policy: &Policy,
tool_name: &str,
_arguments: &str,
approver: Option<&dyn Approver>,
) -> Decision {
if policy.denylist.iter().any(|d| d == tool_name) {
return Decision::Deny;
}
if !policy.allowlist.is_empty() {
if policy.allowlist.iter().any(|a| a == tool_name) {
return Decision::Allow;
}
return Decision::Deny;
}
let mode_decision = match policy.mode {
PermissionMode::FullAccess => Decision::Allow,
PermissionMode::DenyAll => Decision::Deny,
PermissionMode::ReadOnly => {
if is_read_only_tool(tool_name) {
Decision::Allow
} else {
Decision::Ask
}
}
PermissionMode::WorkspaceWrite => Decision::Allow,
};
if mode_decision == Decision::Ask {
if let Some(app) = approver {
let dummy_call = ToolCall {
id: String::new(),
name: tool_name.to_string(),
arguments: String::new(),
};
return app.approve(&dummy_call);
}
}
mode_decision
}
fn is_read_only_tool(name: &str) -> bool {
matches!(
name,
"read"
| "read_file"
| "ls"
| "list_dir"
| "find"
| "find_files"
| "grep"
| "code_intel"
| "cu_see"
| "cu_image"
| "cu_list"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_access_allows() {
assert_eq!(
authorize(&Policy::full_access(), "write", "{}", None),
Decision::Allow
);
}
#[test]
fn deny_all_blocks() {
assert_eq!(
authorize(&Policy::deny_all(), "read", "{}", None),
Decision::Deny
);
}
#[test]
fn denylist_overrides() {
let p = Policy {
mode: PermissionMode::FullAccess,
allowlist: vec![],
denylist: vec!["bash".into()],
};
assert_eq!(authorize(&p, "bash", "{}", None), Decision::Deny);
}
#[test]
fn read_only_allows_reads() {
assert_eq!(
authorize(&Policy::read_only(), "read", "{}", None),
Decision::Allow
);
assert_eq!(
authorize(&Policy::read_only(), "write", "{}", None),
Decision::Ask
);
}
#[test]
fn approver_called_on_ask() {
assert_eq!(
authorize(&Policy::read_only(), "write", "{}", Some(&AlwaysAllow)),
Decision::Allow
);
assert_eq!(
authorize(&Policy::read_only(), "write", "{}", Some(&AlwaysDeny)),
Decision::Deny
);
}
}