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            source: None,
124        };
125        let cancel = tokio_util::sync::CancellationToken::new();
126        let decision = handler.approve(request, cancel).await.unwrap();
127        assert_eq!(decision, ApprovalDecision::AllowAlways);
128    }
129
130    #[tokio::test]
131    async fn test_deny_all_mode_denies() {
132        let handler = AutoApprovalHandler::new(ApprovalMode::DenyAll);
133        let request = ApprovalRequest {
134            title: "Read file".into(),
135            message: "Read /tmp/safe.txt".into(),
136            action_key: None,
137            risk_level: RiskLevel::Safe,
138            raw: None,
139            source: None,
140        };
141        let cancel = tokio_util::sync::CancellationToken::new();
142        let decision = handler.approve(request, cancel).await.unwrap();
143        assert_eq!(decision, ApprovalDecision::Deny);
144    }
145
146    #[tokio::test]
147    async fn test_deny_all_denies_even_safe_operations() {
148        let handler = AutoApprovalHandler::new(ApprovalMode::DenyAll);
149        let request = ApprovalRequest {
150            title: "Safe thing".into(),
151            message: "Perfectly safe".into(),
152            action_key: None,
153            risk_level: RiskLevel::Safe,
154            raw: None,
155            source: None,
156        };
157        let cancel = tokio_util::sync::CancellationToken::new();
158        let decision = handler.approve(request, cancel).await.unwrap();
159        assert_eq!(decision, ApprovalDecision::Deny);
160    }
161
162    #[tokio::test]
163    async fn queued_handler_roundtrips_decision() {
164        let (queue_tx, mut queue_rx) = tokio::sync::mpsc::unbounded_channel();
165        let handler = QueuedApprovalHandler::new(queue_tx);
166        let cancel = tokio_util::sync::CancellationToken::new();
167
168        let request = ApprovalRequest {
169            title: "write_file".to_string(),
170            message: "Write file: src/lib.rs".to_string(),
171            action_key: Some("write_file:src/lib.rs".to_string()),
172            risk_level: RiskLevel::Sensitive,
173            raw: None,
174            source: None,
175        };
176
177        let handle = {
178            let handler = handler.clone();
179            let request = request.clone();
180            let cancel = cancel.clone();
181            tokio::spawn(async move { handler.approve(request, cancel).await })
182        };
183
184        // The UI side receives the queued item and answers "allow always".
185        let item = queue_rx.recv().await.expect("request should be queued");
186        assert_eq!(item.request.title, "write_file");
187        item.decision_tx.send(ApprovalDecision::AllowAlways).expect("UI should be able to answer");
188
189        let decision = handle.await.expect("handler task").expect("approve");
190        assert_eq!(decision, ApprovalDecision::AllowAlways);
191    }
192
193    #[tokio::test]
194    async fn queued_handler_respects_cancel() {
195        let (queue_tx, mut queue_rx) = tokio::sync::mpsc::unbounded_channel();
196        let handler = QueuedApprovalHandler::new(queue_tx);
197        let cancel = tokio_util::sync::CancellationToken::new();
198
199        let request = ApprovalRequest {
200            title: "t".to_string(),
201            message: "m".to_string(),
202            action_key: None,
203            risk_level: RiskLevel::Safe,
204            raw: None,
205            source: None,
206        };
207
208        let handle = {
209            let handler = handler.clone();
210            let request = request.clone();
211            let cancel = cancel.clone();
212            tokio::spawn(async move { handler.approve(request, cancel).await })
213        };
214
215        let _item = queue_rx.recv().await.expect("request queued");
216        cancel.cancel();
217
218        let result = handle.await.expect("handler task");
219        assert!(matches!(result, Err(AgentError::Cancelled)));
220    }
221}