use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use crate::domain::{ApprovalChoice, ApprovalKind, Msg, ToolCallId, TurnId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
Approve,
ApproveAlways,
Deny,
}
impl From<ApprovalChoice> for ApprovalDecision {
fn from(choice: ApprovalChoice) -> Self {
match choice {
ApprovalChoice::Approve => ApprovalDecision::Approve,
ApprovalChoice::ApproveAlways => ApprovalDecision::ApproveAlways,
ApprovalChoice::Deny => ApprovalDecision::Deny,
}
}
}
struct PendingEntry {
tx: oneshot::Sender<ApprovalDecision>,
allowlist_key: String,
}
#[derive(Clone)]
pub struct ApprovalBroker {
pending: Arc<Mutex<HashMap<ToolCallId, PendingEntry>>>,
allowlist: Arc<Mutex<HashSet<String>>>,
msg_tx: mpsc::Sender<Msg>,
}
impl ApprovalBroker {
pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
Self {
pending: Arc::new(Mutex::new(HashMap::new())),
allowlist: Arc::new(Mutex::new(HashSet::new())),
msg_tx,
}
}
pub fn is_allowlisted(&self, key: &str) -> bool {
self.allowlist
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains(key)
}
#[allow(clippy::too_many_arguments)]
pub async fn request(
&self,
token: &CancellationToken,
turn: TurnId,
call_id: ToolCallId,
tool: String,
risk: String,
kind: ApprovalKind,
prompt: String,
allowlist_key: String,
) -> ApprovalDecision {
let (tx, rx) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(
call_id,
PendingEntry {
tx,
allowlist_key: allowlist_key.clone(),
},
);
let sent = self
.msg_tx
.send(Msg::ApprovalRequested {
turn,
call_id,
tool,
risk,
kind,
prompt,
allowlist_scope: allowlist_key,
})
.await;
if sent.is_err() {
self.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&call_id);
return ApprovalDecision::Deny;
}
tokio::select! {
biased;
_ = token.cancelled() => {
self.pending.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&call_id);
ApprovalDecision::Deny
}
decision = rx => decision.unwrap_or(ApprovalDecision::Deny),
}
}
pub fn resolve(&self, call_id: ToolCallId, decision: ApprovalDecision) {
let entry = self
.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&call_id);
if let Some(entry) = entry {
if decision == ApprovalDecision::ApproveAlways && !entry.allowlist_key.is_empty() {
self.allowlist
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(entry.allowlist_key);
}
let _ = entry.tx.send(decision);
}
}
}
const NON_ALLOWLISTABLE_TOOLS: &[&str] = &[
"type_text",
"press_key",
"click",
"mouse_move",
"scroll",
"mcp_proxy",
];
pub fn allowlist_key(tool: &str, command: Option<&str>) -> String {
if NON_ALLOWLISTABLE_TOOLS.contains(&tool) {
return String::new();
}
if tool == "execute_command" {
if let Some(cmd) = command {
let normalized = cmd.split_whitespace().collect::<Vec<_>>().join(" ");
if !normalized.is_empty() {
return format!("execute_command:{normalized}");
}
}
return "execute_command".to_string();
}
tool.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allowlist_key_is_per_tool_with_full_command() {
assert_eq!(allowlist_key("write_file", None), "write_file");
assert_eq!(
allowlist_key("execute_command", Some("ls -la")),
"execute_command:ls -la"
);
assert_eq!(allowlist_key("execute_command", None), "execute_command");
}
#[test]
fn allowlist_key_distinguishes_argument_variants() {
assert_ne!(
allowlist_key("execute_command", Some("curl https://safe.example")),
allowlist_key("execute_command", Some("curl https://evil.example")),
);
assert_eq!(
allowlist_key("execute_command", Some("cargo build")),
allowlist_key("execute_command", Some("cargo build")),
);
assert_eq!(
allowlist_key("execute_command", Some("npm test")),
"execute_command:npm test"
);
assert_ne!(
allowlist_key("execute_command", Some("npm test")),
allowlist_key("execute_command", Some("npm run build")),
);
}
#[test]
fn content_bearing_tools_are_non_allowlistable() {
for tool in [
"type_text",
"press_key",
"click",
"mouse_move",
"scroll",
"mcp_proxy",
] {
assert_eq!(
allowlist_key(tool, None),
"",
"{tool} must be non-allowlistable"
);
}
}
#[tokio::test]
async fn resolve_delivers_decision_and_approve_always_allowlists() {
let (tx, _rx) = mpsc::channel::<Msg>(8);
let broker = ApprovalBroker::new(tx);
let token = CancellationToken::new();
let b2 = broker.clone();
let handle = tokio::spawn(async move {
b2.request(
&CancellationToken::new(),
TurnId(1),
ToolCallId(1),
"execute_command".to_string(),
"shell_mutation".to_string(),
ApprovalKind::Shell,
"$ npm test".to_string(),
"execute_command:npm".to_string(),
)
.await
});
tokio::task::yield_now().await;
for _ in 0..100 {
broker.resolve(ToolCallId(1), ApprovalDecision::ApproveAlways);
if broker.is_allowlisted("execute_command:npm") {
break;
}
tokio::task::yield_now().await;
}
let decision = handle.await.unwrap();
assert_eq!(decision, ApprovalDecision::ApproveAlways);
assert!(broker.is_allowlisted("execute_command:npm"));
let _ = token; }
#[tokio::test]
async fn cancel_token_denies() {
let (tx, _rx) = mpsc::channel::<Msg>(8);
let broker = ApprovalBroker::new(tx);
let token = CancellationToken::new();
let token2 = token.clone();
let handle = tokio::spawn(async move {
broker
.request(
&token2,
TurnId(1),
ToolCallId(2),
"web_fetch".to_string(),
"network".to_string(),
ApprovalKind::Web,
"web_fetch https://x".to_string(),
"web_fetch".to_string(),
)
.await
});
tokio::task::yield_now().await;
token.cancel();
assert_eq!(handle.await.unwrap(), ApprovalDecision::Deny);
}
}