Skip to main content

phi_agent/cli/
approval.rs

1//! Approval strategies for tool calls.
2//!
3//! Provides [`AutoApprovalHandler`] which implements a pure-strategy
4//! approval model — automatically approve all calls or deny all calls
5//! — suitable for CLI, CI, and headless environments.
6//!
7//! Also provides [`QueuedApprovalHandler`] for UI-fronted runtimes: instead
8//! of touching the terminal it pushes each request into a queue the UI
9//! drains, and waits on a per-request oneshot for the user's decision.
10
11use agent_base::{AgentError, AgentResult, ApprovalDecision, ApprovalHandler, ApprovalRequest};
12use async_trait::async_trait;
13use tokio::sync::{mpsc, oneshot};
14
15/// Approval strategy.
16#[derive(Clone, Debug)]
17pub enum ApprovalMode {
18    /// Automatically approve all tool calls without user interaction.
19    Auto,
20    /// Reject all tool calls that require approval.
21    DenyAll,
22}
23
24/// I/O-free approval handler — pure strategy, suitable for all consumers.
25///
26/// Use [`ApprovalMode::Auto`] for automated/CI scenarios, or
27/// [`ApprovalMode::DenyAll`] for read-only / preview modes.
28pub struct AutoApprovalHandler {
29    mode: ApprovalMode,
30}
31
32impl AutoApprovalHandler {
33    /// Create a new handler with the given approval strategy.
34    pub fn new(mode: ApprovalMode) -> Self {
35        Self { mode }
36    }
37}
38
39#[async_trait]
40impl ApprovalHandler for AutoApprovalHandler {
41    async fn approve(
42        &self,
43        request: ApprovalRequest,
44        _cancel_token: tokio_util::sync::CancellationToken,
45    ) -> AgentResult<ApprovalDecision> {
46        match &self.mode {
47            ApprovalMode::Auto => {
48                tracing::info!(decision = "AllowAlways", tool = %request.title, "auto-approved");
49                Ok(ApprovalDecision::AllowAlways)
50            },
51            ApprovalMode::DenyAll => {
52                tracing::info!(decision = "Deny", tool = %request.title, "auto-denied");
53                Ok(ApprovalDecision::Deny)
54            },
55        }
56    }
57}
58
59// ── QueuedApprovalHandler ───────────────────────────────────────────────────
60
61/// A pending approval request handed to the UI, carrying the channel the
62/// user's decision is returned on.
63#[derive(Debug)]
64pub struct ApprovalItem {
65    /// The request to render (title, message, risk level, raw args).
66    pub request: ApprovalRequest,
67    /// Complete this to deliver the user's decision back to the runtime.
68    pub decision_tx: oneshot::Sender<ApprovalDecision>,
69}
70
71/// Queued approval handler: enqueue each request and let the UI render one
72/// prompt at a time, then return the user's decision.
73///
74/// Unlike [`AutoApprovalHandler`] (a pure strategy) this never decides on its
75/// own and never touches the terminal. It pushes the request into a queue the
76/// UI drains and waits on a per-request oneshot. Parallel sub-agents each call
77/// `approve`; the queue serializes them so only one prompt is shown at a time
78/// (fixing interleaved multi-sub-agent prompts in REPL UIs).
79#[derive(Debug, Clone)]
80pub struct QueuedApprovalHandler {
81    queue_tx: mpsc::UnboundedSender<ApprovalItem>,
82}
83
84impl QueuedApprovalHandler {
85    /// Create a handler wired to `queue_tx`. The caller keeps the matching
86    /// `UnboundedReceiver` and feeds it to the UI.
87    pub fn new(queue_tx: mpsc::UnboundedSender<ApprovalItem>) -> Self {
88        Self { queue_tx }
89    }
90}
91
92#[async_trait]
93impl ApprovalHandler for QueuedApprovalHandler {
94    async fn approve(
95        &self,
96        request: ApprovalRequest,
97        cancel_token: tokio_util::sync::CancellationToken,
98    ) -> AgentResult<ApprovalDecision> {
99        let (decision_tx, decision_rx) = oneshot::channel();
100        let _ = self.queue_tx.send(ApprovalItem { request, decision_tx });
101
102        tokio::select! {
103            _ = cancel_token.cancelled() => Err(AgentError::Cancelled),
104            result = decision_rx => result.map_err(|_| AgentError::Cancelled),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use agent_base::{ApprovalRequest, RiskLevel};
113
114    #[tokio::test]
115    async fn test_auto_mode_approves_all() {
116        let handler = AutoApprovalHandler::new(ApprovalMode::Auto);
117        let request = ApprovalRequest {
118            title: "Delete file".into(),
119            message: "This will delete /tmp/important.txt".into(),
120            action_key: None,
121            risk_level: RiskLevel::Destructive,
122            raw: None,
123        };
124        let cancel = tokio_util::sync::CancellationToken::new();
125        let decision = handler.approve(request, cancel).await.unwrap();
126        assert_eq!(decision, ApprovalDecision::AllowAlways);
127    }
128
129    #[tokio::test]
130    async fn test_deny_all_mode_denies() {
131        let handler = AutoApprovalHandler::new(ApprovalMode::DenyAll);
132        let request = ApprovalRequest {
133            title: "Read file".into(),
134            message: "Read /tmp/safe.txt".into(),
135            action_key: None,
136            risk_level: RiskLevel::Safe,
137            raw: None,
138        };
139        let cancel = tokio_util::sync::CancellationToken::new();
140        let decision = handler.approve(request, cancel).await.unwrap();
141        assert_eq!(decision, ApprovalDecision::Deny);
142    }
143
144    #[tokio::test]
145    async fn test_deny_all_denies_even_safe_operations() {
146        let handler = AutoApprovalHandler::new(ApprovalMode::DenyAll);
147        let request = ApprovalRequest {
148            title: "Safe thing".into(),
149            message: "Perfectly safe".into(),
150            action_key: None,
151            risk_level: RiskLevel::Safe,
152            raw: None,
153        };
154        let cancel = tokio_util::sync::CancellationToken::new();
155        let decision = handler.approve(request, cancel).await.unwrap();
156        assert_eq!(decision, ApprovalDecision::Deny);
157    }
158
159    #[tokio::test]
160    async fn queued_handler_roundtrips_decision() {
161        let (queue_tx, mut queue_rx) = tokio::sync::mpsc::unbounded_channel();
162        let handler = QueuedApprovalHandler::new(queue_tx);
163        let cancel = tokio_util::sync::CancellationToken::new();
164
165        let request = ApprovalRequest {
166            title: "write_file".to_string(),
167            message: "Write file: src/lib.rs".to_string(),
168            action_key: Some("write_file:src/lib.rs".to_string()),
169            risk_level: RiskLevel::Sensitive,
170            raw: None,
171        };
172
173        let handle = {
174            let handler = handler.clone();
175            let request = request.clone();
176            let cancel = cancel.clone();
177            tokio::spawn(async move { handler.approve(request, cancel).await })
178        };
179
180        // The UI side receives the queued item and answers "allow always".
181        let item = queue_rx.recv().await.expect("request should be queued");
182        assert_eq!(item.request.title, "write_file");
183        item.decision_tx.send(ApprovalDecision::AllowAlways).expect("UI should be able to answer");
184
185        let decision = handle.await.expect("handler task").expect("approve");
186        assert_eq!(decision, ApprovalDecision::AllowAlways);
187    }
188
189    #[tokio::test]
190    async fn queued_handler_respects_cancel() {
191        let (queue_tx, mut queue_rx) = tokio::sync::mpsc::unbounded_channel();
192        let handler = QueuedApprovalHandler::new(queue_tx);
193        let cancel = tokio_util::sync::CancellationToken::new();
194
195        let request = ApprovalRequest {
196            title: "t".to_string(),
197            message: "m".to_string(),
198            action_key: None,
199            risk_level: RiskLevel::Safe,
200            raw: None,
201        };
202
203        let handle = {
204            let handler = handler.clone();
205            let request = request.clone();
206            let cancel = cancel.clone();
207            tokio::spawn(async move { handler.approve(request, cancel).await })
208        };
209
210        let _item = queue_rx.recv().await.expect("request queued");
211        cancel.cancel();
212
213        let result = handle.await.expect("handler task");
214        assert!(matches!(result, Err(AgentError::Cancelled)));
215    }
216}