use std::collections::HashMap; use std::sync::Mutex; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use ulid::Ulid; use crate::event::{AppEvent, EventBus, PermissionRequest}; use crate::types::SessionId; use super::rule::{evaluate, evaluate_intersected, Action, Rule, Ruleset}; pub struct AskInput { pub permission: String, /// Pattern evaluated against the ruleset stack, and granted on a `Once` reply. pub pattern: String, /// Coarser pattern granted (as an `Allow` rule on `session.extra_rules`) on an `Always` reply. pub always_pattern: String, pub metadata: serde_json::Value, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionReply { Once, Always, Reject, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum AskError { #[error("permission denied by ruleset")] Denied, #[error("permission rejected by user")] Rejected, #[error("cancelled")] Cancelled, } #[derive(Debug)] pub enum AskDecision { /// Ruleset allowed it outright, or the user replied `Once`. Allowed, /// User replied `Always` — caller persists this rule onto `session.extra_rules`. AllowedAlways(Rule), } /// Port of opencode's permission/index.ts ask flow: evaluate the ruleset stack first; /// only fall through to a blocking, event-published ask when the verdict is `Ask`. pub struct PermissionService { bus: EventBus, pending: Mutex>>, } impl PermissionService { pub fn new(bus: EventBus) -> Self { Self { bus, pending: Mutex::new(HashMap::new()), } } pub async fn ask( &self, session_id: &SessionId, stack: &[&Ruleset], input: AskInput, cancel: &CancellationToken, ) -> Result { match evaluate(stack, &input.permission, &input.pattern) { Action::Allow => Ok(AskDecision::Allowed), Action::Deny => Err(AskError::Denied), Action::Ask => self.ask_user(session_id, input, cancel).await, } } /// Like [`ask`](Self::ask), but for a subagent: the verdict is the more restrictive of /// the `parent_stack` (rules inherited from the spawning chain) and `child_stack` (the /// subagent's own rules). Used so a child can never widen what its parent forbids. pub async fn ask_intersected( &self, session_id: &SessionId, parent_stack: &[&Ruleset], child_stack: &[&Ruleset], input: AskInput, cancel: &CancellationToken, ) -> Result { match evaluate_intersected(parent_stack, child_stack, &input.permission, &input.pattern) { Action::Allow => Ok(AskDecision::Allowed), Action::Deny => Err(AskError::Denied), Action::Ask => self.ask_user(session_id, input, cancel).await, } } /// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask /// regardless of any `Allow` rule. pub async fn force_ask( &self, session_id: &SessionId, input: AskInput, cancel: &CancellationToken, ) -> Result { self.ask_user(session_id, input, cancel).await } async fn ask_user( &self, session_id: &SessionId, input: AskInput, cancel: &CancellationToken, ) -> Result { let id = Ulid::new().to_string(); let (tx, rx) = oneshot::channel(); self.pending.lock().unwrap().insert(id.clone(), tx); self.bus.publish(AppEvent::PermissionAsked { request: PermissionRequest { id: id.clone(), session_id: session_id.clone(), permission: input.permission.clone(), pattern: input.pattern.clone(), always_pattern: input.always_pattern.clone(), metadata: input.metadata.clone(), }, }); let reply = tokio::select! { reply = rx => reply.map_err(|_| AskError::Cancelled)?, _ = cancel.cancelled() => { self.pending.lock().unwrap().remove(&id); return Err(AskError::Cancelled); } }; self.bus.publish(AppEvent::PermissionResolved { id }); match reply { PermissionReply::Once => Ok(AskDecision::Allowed), PermissionReply::Always => Ok(AskDecision::AllowedAlways(Rule { permission: input.permission, pattern: input.always_pattern, action: Action::Allow, })), PermissionReply::Reject => Err(AskError::Rejected), } } /// Resolve a pending ask (called by a frontend: TUI keypress, auto-approve stub, ...). /// Returns `false` if `id` had already been resolved or never existed. pub fn reply(&self, id: &str, reply: PermissionReply) -> bool { match self.pending.lock().unwrap().remove(id) { Some(tx) => tx.send(reply).is_ok(), None => false, } } } /// Auto-approves every ask as `Once` — the M1..M2 stand-in for a real TUI permission modal. pub fn spawn_auto_approve(bus: EventBus, service: std::sync::Arc) { let mut rx = bus.subscribe(); tokio::spawn(async move { while let Ok(event) = rx.recv().await { if let AppEvent::PermissionAsked { request } = event { service.reply(&request.id, PermissionReply::Once); } } }); } #[cfg(test)] mod tests { use super::*; fn rule(permission: &str, pattern: &str, action: Action) -> Rule { Rule { permission: permission.to_string(), pattern: pattern.to_string(), action, } } #[tokio::test] async fn allow_rule_short_circuits_without_asking() { let service = PermissionService::new(EventBus::new()); let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)]; let outcome = service .ask( &SessionId::new(), &[&rules], AskInput { permission: "bash".into(), pattern: "ls -la".into(), always_pattern: "ls *".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await .unwrap(); assert!(matches!(outcome, AskDecision::Allowed)); } #[tokio::test] async fn deny_rule_short_circuits_to_denied_error() { let service = PermissionService::new(EventBus::new()); let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)]; let err = service .ask( &SessionId::new(), &[&rules], AskInput { permission: "edit".into(), pattern: "Cargo.lock".into(), always_pattern: "Cargo.lock".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await .unwrap_err(); assert_eq!(err, AskError::Denied); } #[tokio::test] async fn ask_publishes_event_and_waits_for_reply() { let bus = EventBus::new(); let mut sub = bus.subscribe(); let service = std::sync::Arc::new(PermissionService::new(bus)); let svc = service.clone(); let handle = tokio::spawn(async move { svc.ask( &SessionId::new(), &[], AskInput { permission: "bash".into(), pattern: "rm -rf /".into(), always_pattern: "rm *".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await }); let event = sub.recv().await.unwrap(); let id = match event { AppEvent::PermissionAsked { request } => request.id, _ => panic!("expected PermissionAsked"), }; assert!(service.reply(&id, PermissionReply::Once)); let outcome = handle.await.unwrap().unwrap(); assert!(matches!(outcome, AskDecision::Allowed)); } #[tokio::test] async fn always_reply_yields_rule_to_persist() { let bus = EventBus::new(); let mut sub = bus.subscribe(); let service = std::sync::Arc::new(PermissionService::new(bus)); let svc = service.clone(); let handle = tokio::spawn(async move { svc.ask( &SessionId::new(), &[], AskInput { permission: "bash".into(), pattern: "git push origin main".into(), always_pattern: "git push*".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await }); let request = match sub.recv().await.unwrap() { AppEvent::PermissionAsked { request } => request, _ => panic!("expected PermissionAsked"), }; service.reply(&request.id, PermissionReply::Always); let outcome = handle.await.unwrap().unwrap(); match outcome { AskDecision::AllowedAlways(rule) => { assert_eq!(rule.permission, "bash"); assert_eq!(rule.pattern, "git push*"); assert_eq!(rule.action, Action::Allow); } _ => panic!("expected AllowedAlways"), } } #[tokio::test] async fn reject_reply_yields_rejected_error() { let bus = EventBus::new(); let mut sub = bus.subscribe(); let service = std::sync::Arc::new(PermissionService::new(bus)); let svc = service.clone(); let handle = tokio::spawn(async move { svc.ask( &SessionId::new(), &[], AskInput { permission: "bash".into(), pattern: "rm -rf /".into(), always_pattern: "rm *".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await }); let request = match sub.recv().await.unwrap() { AppEvent::PermissionAsked { request } => request, _ => panic!("expected PermissionAsked"), }; service.reply(&request.id, PermissionReply::Reject); let err = handle.await.unwrap().unwrap_err(); assert_eq!(err, AskError::Rejected); } #[tokio::test] async fn cancellation_aborts_a_pending_ask() { let bus = EventBus::new(); let service = std::sync::Arc::new(PermissionService::new(bus)); let cancel = CancellationToken::new(); let cancel2 = cancel.clone(); let svc = service.clone(); let handle = tokio::spawn(async move { svc.ask( &SessionId::new(), &[], AskInput { permission: "bash".into(), pattern: "rm -rf /".into(), always_pattern: "rm *".into(), metadata: serde_json::Value::Null, }, &cancel2, ) .await }); cancel.cancel(); let err = handle.await.unwrap().unwrap_err(); assert_eq!(err, AskError::Cancelled); } #[tokio::test] async fn auto_approve_resolves_asks_as_once() { let bus = EventBus::new(); let service = std::sync::Arc::new(PermissionService::new(bus.clone())); spawn_auto_approve(bus, service.clone()); let outcome = service .ask( &SessionId::new(), &[], AskInput { permission: "bash".into(), pattern: "ls".into(), always_pattern: "ls *".into(), metadata: serde_json::Value::Null, }, &CancellationToken::new(), ) .await .unwrap(); assert!(matches!(outcome, AskDecision::Allowed)); } }