use crate::tool::{ApprovalRequirement, PermissionDecision, ToolCall};
use async_trait::async_trait;
use tokio::sync::mpsc;
#[async_trait]
pub trait PermissionDecider: Send + Sync {
async fn decide(&self, call: &ToolCall, approval: &ApprovalRequirement) -> PermissionDecision;
fn will_auto_approve(&self, call: &ToolCall, approval: &ApprovalRequirement) -> bool;
}
#[derive(Debug, Clone)]
pub enum AutoPermissionMode {
BypassAll,
AcceptEdits,
DenyAll,
}
const EDIT_TOOLS: &[&str] = &["create_file", "edit_file", "search_replace"];
pub struct AutoPermissionDecider {
mode: AutoPermissionMode,
}
impl AutoPermissionDecider {
pub fn new(mode: AutoPermissionMode) -> Self {
Self { mode }
}
}
#[async_trait]
impl PermissionDecider for AutoPermissionDecider {
async fn decide(&self, call: &ToolCall, _approval: &ApprovalRequirement) -> PermissionDecision {
match self.mode {
AutoPermissionMode::BypassAll => PermissionDecision::Allow,
AutoPermissionMode::AcceptEdits => {
if EDIT_TOOLS.contains(&call.name.as_str()) {
PermissionDecision::Allow
} else {
PermissionDecision::Deny
}
}
AutoPermissionMode::DenyAll => PermissionDecision::Deny,
}
}
fn will_auto_approve(&self, call: &ToolCall, _approval: &ApprovalRequirement) -> bool {
match self.mode {
AutoPermissionMode::BypassAll => true,
AutoPermissionMode::AcceptEdits => EDIT_TOOLS.contains(&call.name.as_str()),
AutoPermissionMode::DenyAll => false,
}
}
}
#[derive(Debug)]
pub struct ApprovalRequest {
pub call: ToolCall,
pub reason: String,
}
pub struct InteractivePermissionDecider {
request_tx: mpsc::UnboundedSender<ApprovalRequest>,
response_rx: tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionDecision>>,
permission_store: std::sync::Arc<std::sync::RwLock<crate::tool::PermissionStore>>,
dangerously_skip_permissions: bool,
}
impl InteractivePermissionDecider {
pub fn new(
request_tx: mpsc::UnboundedSender<ApprovalRequest>,
response_rx: mpsc::UnboundedReceiver<PermissionDecision>,
permission_store: std::sync::Arc<std::sync::RwLock<crate::tool::PermissionStore>>,
) -> Self {
Self {
request_tx,
response_rx: tokio::sync::Mutex::new(response_rx),
permission_store,
dangerously_skip_permissions: false,
}
}
pub fn new_with_skip_permissions(
request_tx: mpsc::UnboundedSender<ApprovalRequest>,
response_rx: mpsc::UnboundedReceiver<PermissionDecision>,
permission_store: std::sync::Arc<std::sync::RwLock<crate::tool::PermissionStore>>,
skip: bool,
) -> Self {
Self {
request_tx,
response_rx: tokio::sync::Mutex::new(response_rx),
permission_store,
dangerously_skip_permissions: skip,
}
}
}
#[async_trait]
impl PermissionDecider for InteractivePermissionDecider {
async fn decide(&self, call: &ToolCall, approval: &ApprovalRequirement) -> PermissionDecision {
if self.dangerously_skip_permissions {
return PermissionDecision::Allow;
}
if let Ok(store) = self.permission_store.read() {
match store.check(&call.name, approval) {
PermissionDecision::Allow => return PermissionDecision::Allow,
PermissionDecision::Deny => return PermissionDecision::Deny,
PermissionDecision::Ask(_) => {}
}
}
let reason = match approval {
ApprovalRequirement::RequireApproval(r)
| ApprovalRequirement::RequireApprovalAlways(r) => r.clone(),
ApprovalRequirement::AutoApprove => return PermissionDecision::Allow,
};
let request = ApprovalRequest {
call: call.clone(),
reason,
};
if self.request_tx.send(request).is_err() {
return PermissionDecision::Deny;
}
let mut rx = self.response_rx.lock().await;
rx.recv().await.unwrap_or(PermissionDecision::Deny)
}
fn will_auto_approve(&self, call: &ToolCall, approval: &ApprovalRequirement) -> bool {
if self.dangerously_skip_permissions {
return true;
}
if matches!(approval, ApprovalRequirement::AutoApprove) {
return true;
}
if let Ok(store) = self.permission_store.read() {
matches!(store.check(&call.name, approval), PermissionDecision::Allow)
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_call(name: &str) -> ToolCall {
ToolCall {
id: "test".into(),
name: name.into(),
arguments: "{}".into(),
}
}
#[tokio::test]
async fn test_auto_bypass_allows_all() {
let d = AutoPermissionDecider::new(AutoPermissionMode::BypassAll);
assert!(matches!(
d.decide(&make_call("bash"), &ApprovalRequirement::RequireApproval("dangerous".into())).await,
PermissionDecision::Allow
));
}
#[tokio::test]
async fn test_auto_deny_denies_all() {
let d = AutoPermissionDecider::new(AutoPermissionMode::DenyAll);
assert!(matches!(
d.decide(&make_call("bash"), &ApprovalRequirement::RequireApproval("dangerous".into())).await,
PermissionDecision::Deny
));
}
#[tokio::test]
async fn test_auto_accept_edits_allows_write() {
let d = AutoPermissionDecider::new(AutoPermissionMode::AcceptEdits);
assert!(matches!(
d.decide(&make_call("create_file"), &ApprovalRequirement::RequireApproval("write".into())).await,
PermissionDecision::Allow
));
assert!(matches!(
d.decide(&make_call("edit_file"), &ApprovalRequirement::RequireApproval("edit".into())).await,
PermissionDecision::Allow
));
assert!(matches!(
d.decide(&make_call("search_replace"), &ApprovalRequirement::RequireApproval("sr".into())).await,
PermissionDecision::Allow
));
}
#[tokio::test]
async fn test_auto_accept_edits_denies_bash() {
let d = AutoPermissionDecider::new(AutoPermissionMode::AcceptEdits);
assert!(matches!(
d.decide(&make_call("bash"), &ApprovalRequirement::RequireApproval("dangerous".into())).await,
PermissionDecision::Deny
));
}
#[tokio::test]
async fn test_interactive_allow() {
let (req_tx, mut req_rx) = mpsc::unbounded_channel();
let (resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
let approval = ApprovalRequirement::RequireApproval("dangerous".into());
let fut = d.decide(&call, &approval);
tokio::spawn(async move {
let _req = req_rx.recv().await.unwrap();
resp_tx.send(PermissionDecision::Allow).unwrap();
});
assert!(matches!(fut.await, PermissionDecision::Allow));
}
#[tokio::test]
async fn test_interactive_deny() {
let (req_tx, mut req_rx) = mpsc::unbounded_channel();
let (resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
let approval = ApprovalRequirement::RequireApproval("dangerous".into());
let fut = d.decide(&call, &approval);
tokio::spawn(async move {
let _req = req_rx.recv().await.unwrap();
resp_tx.send(PermissionDecision::Deny).unwrap();
});
assert!(matches!(fut.await, PermissionDecision::Deny));
}
#[tokio::test]
async fn test_interactive_channel_closed_returns_deny() {
let (req_tx, req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
drop(req_rx);
let call = make_call("bash");
assert!(matches!(
d.decide(&call, &ApprovalRequirement::RequireApproval("dangerous".into())).await,
PermissionDecision::Deny
));
}
#[tokio::test]
async fn test_interactive_session_grant_skips_channel() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("bash");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
let decision = d.decide(&call, &ApprovalRequirement::RequireApproval("dangerous".into())).await;
assert!(matches!(decision, PermissionDecision::Allow));
}
#[tokio::test]
async fn test_interactive_require_approval_always_with_grant_still_prompts() {
let (req_tx, mut req_rx) = mpsc::unbounded_channel();
let (resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("bash");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
let approval = ApprovalRequirement::RequireApprovalAlways("sensitive".into());
let fut = d.decide(&call, &approval);
tokio::spawn(async move {
let _req = req_rx.recv().await.expect("channel must receive request");
resp_tx.send(PermissionDecision::Deny).unwrap();
});
assert!(matches!(fut.await, PermissionDecision::Deny));
}
#[test]
fn test_will_auto_approve_auto_bypass() {
let d = AutoPermissionDecider::new(AutoPermissionMode::BypassAll);
let call = make_call("bash");
assert!(d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())));
}
#[test]
fn test_will_auto_approve_auto_deny() {
let d = AutoPermissionDecider::new(AutoPermissionMode::DenyAll);
let call = make_call("bash");
assert!(!d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())));
}
#[test]
fn test_will_auto_approve_auto_accept_edits() {
let d = AutoPermissionDecider::new(AutoPermissionMode::AcceptEdits);
let edit_call = make_call("edit_file");
let bash_call = make_call("bash");
assert!(d.will_auto_approve(&edit_call, &ApprovalRequirement::RequireApproval("write".into())));
assert!(!d.will_auto_approve(&bash_call, &ApprovalRequirement::RequireApproval("dangerous".into())));
}
#[test]
fn test_will_auto_approve_interactive_no_grant() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
assert!(!d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())));
}
#[test]
fn test_will_auto_approve_interactive_with_session_grant() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("bash");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
assert!(d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())));
}
#[test]
fn test_will_auto_approve_interactive_require_approval_always_with_grant() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("bash");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
assert!(!d.will_auto_approve(&call, &ApprovalRequirement::RequireApprovalAlways("sensitive".into())));
}
#[test]
fn test_will_auto_approve_interactive_require_approval_always_no_grant() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
assert!(!d.will_auto_approve(&call, &ApprovalRequirement::RequireApprovalAlways("sensitive".into())));
}
#[test]
fn test_will_auto_approve_interactive_different_tool_not_auto() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("bash");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("mcp__zouwu__query");
assert!(!d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("mcp tool".into())));
}
#[test]
fn test_will_auto_approve_interactive_same_tool_auto() {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
store.write().unwrap().grant_session("mcp__zouwu-mcp-server__query_requirements");
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("mcp__zouwu-mcp-server__query_requirements");
assert!(d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("mcp tool".into())));
}
fn make_skip_decider() -> InteractivePermissionDecider {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
InteractivePermissionDecider::new_with_skip_permissions(req_tx, resp_rx, store, true)
}
fn make_normal_decider() -> InteractivePermissionDecider {
let (req_tx, _req_rx) = mpsc::unbounded_channel();
let (_resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
InteractivePermissionDecider::new(req_tx, resp_rx, store)
}
#[tokio::test]
async fn test_skip_permissions_auto_approves_require_approval() {
let d = make_skip_decider();
let call = make_call("bash");
let decision = d
.decide(&call, &ApprovalRequirement::RequireApproval("needs approval".into()))
.await;
assert!(
matches!(decision, PermissionDecision::Allow),
"skip_permissions should auto-approve RequireApproval"
);
}
#[tokio::test]
async fn test_skip_permissions_auto_approves_require_approval_always() {
let d = make_skip_decider();
let call = make_call("bash");
let decision = d
.decide(&call, &ApprovalRequirement::RequireApprovalAlways("sensitive".into()))
.await;
assert!(
matches!(decision, PermissionDecision::Allow),
"skip_permissions should auto-approve even RequireApprovalAlways"
);
}
#[tokio::test]
async fn test_skip_permissions_auto_approves_auto_approve() {
let d = make_skip_decider();
let call = make_call("read_file");
let decision = d
.decide(&call, &ApprovalRequirement::AutoApprove)
.await;
assert!(
matches!(decision, PermissionDecision::Allow),
"skip_permissions should auto-approve AutoApprove"
);
}
#[tokio::test]
async fn test_skip_permissions_auto_approves_mcp_tool() {
let d = make_skip_decider();
let call = make_call("mcp__zouwu__query");
let decision = d
.decide(&call, &ApprovalRequirement::RequireApproval("mcp tool".into()))
.await;
assert!(
matches!(decision, PermissionDecision::Allow),
"skip_permissions should auto-approve MCP tools"
);
}
#[tokio::test]
async fn test_normal_mode_still_prompts_require_approval() {
let (req_tx, mut req_rx) = mpsc::unbounded_channel();
let (resp_tx, resp_rx) = mpsc::unbounded_channel();
let store =
std::sync::Arc::new(std::sync::RwLock::new(crate::tool::PermissionStore::new()));
let d = InteractivePermissionDecider::new(req_tx, resp_rx, store);
let call = make_call("bash");
let approval = ApprovalRequirement::RequireApproval("dangerous".into());
let fut = d.decide(&call, &approval);
tokio::spawn(async move {
let req = req_rx.recv().await.expect("should receive approval request");
assert_eq!(req.call.name, "bash");
resp_tx.send(PermissionDecision::Deny).unwrap();
});
assert!(
matches!(fut.await, PermissionDecision::Deny),
"normal mode should not auto-approve RequireApproval"
);
}
#[test]
fn test_will_auto_approve_skip_permissions_require_approval() {
let d = make_skip_decider();
let call = make_call("bash");
assert!(
d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())),
"skip_permissions: will_auto_approve should return true for RequireApproval"
);
}
#[test]
fn test_will_auto_approve_skip_permissions_require_approval_always() {
let d = make_skip_decider();
let call = make_call("bash");
assert!(
d.will_auto_approve(&call, &ApprovalRequirement::RequireApprovalAlways("sensitive".into())),
"skip_permissions: will_auto_approve should return true even for RequireApprovalAlways"
);
}
#[test]
fn test_will_auto_approve_skip_permissions_auto_approve() {
let d = make_skip_decider();
let call = make_call("read_file");
assert!(
d.will_auto_approve(&call, &ApprovalRequirement::AutoApprove),
"skip_permissions: will_auto_approve should return true for AutoApprove"
);
}
#[test]
fn test_will_auto_approve_skip_permissions_mcp_tool() {
let d = make_skip_decider();
let call = make_call("mcp__custom__query");
assert!(
d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("mcp".into())),
"skip_permissions: will_auto_approve should return true for MCP tools"
);
}
#[test]
fn test_will_auto_approve_normal_mode_require_approval_is_false() {
let d = make_normal_decider();
let call = make_call("bash");
assert!(
!d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("dangerous".into())),
"normal mode: will_auto_approve should return false for RequireApproval without session grant"
);
}
#[test]
fn test_will_auto_approve_normal_mode_require_approval_always_is_false() {
let d = make_normal_decider();
let call = make_call("bash");
assert!(
!d.will_auto_approve(&call, &ApprovalRequirement::RequireApprovalAlways("sensitive".into())),
"normal mode: will_auto_approve should return false for RequireApprovalAlways"
);
}
#[test]
fn test_new_defaults_to_skip_false() {
let d = make_normal_decider();
let call = make_call("bash");
assert!(
!d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("test".into())),
"new() should default to skip_permissions=false"
);
}
#[test]
fn test_new_with_skip_permissions_true() {
let d = make_skip_decider();
let call = make_call("bash");
assert!(
d.will_auto_approve(&call, &ApprovalRequirement::RequireApproval("test".into())),
"new_with_skip_permissions(true) should set skip_permissions=true"
);
}
}