Skip to main content

oxios_kernel/tools/
pending_tool_approvals.rs

1//! Pending tool approvals — runtime capability escalation via user consent.
2//!
3//! When a `GatedTool` denies a tool call due to missing CSpace capabilities,
4//! it can register a pending approval here and block on a oneshot. The frontend
5//! renders an approval card; the user's decision resolves the oneshot.
6//!
7//! Pattern: identical to `PendingQuestionnaires` (RFC-016).
8
9use parking_lot::Mutex;
10use std::collections::HashMap;
11use tokio::sync::oneshot;
12use uuid::Uuid;
13
14/// Result of a tool approval request.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ToolApprovalResult {
17    /// User approved — retry the tool call.
18    Approved,
19    /// User denied — return error to agent.
20    Denied,
21}
22
23struct PendingEntry {
24    tool_name: String,
25    grant_key: String,
26    sender: oneshot::Sender<ToolApprovalResult>,
27}
28
29/// Thread-safe registry of in-flight tool approval requests.
30#[derive(Default)]
31pub struct PendingToolApprovals {
32    inner: Mutex<HashMap<Uuid, PendingEntry>>,
33}
34
35impl PendingToolApprovals {
36    /// Create a new empty registry.
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    /// Register a new pending tool approval.
42    /// Returns the approval ID and a receiver to await the user's decision.
43    pub fn register(
44        &self,
45        tool_name: String,
46        grant_key: String,
47    ) -> (Uuid, oneshot::Receiver<ToolApprovalResult>) {
48        let id = Uuid::new_v4();
49        let (tx, rx) = oneshot::channel();
50        self.inner.lock().insert(
51            id,
52            PendingEntry {
53                tool_name,
54                grant_key,
55                sender: tx,
56            },
57        );
58        (id, rx)
59    }
60
61    /// Resolve a pending approval with the user's decision.
62    /// Returns the tool name if the entry existed.
63    pub fn resolve(&self, id: Uuid, result: ToolApprovalResult) -> Option<String> {
64        let entry = self.inner.lock().remove(&id)?;
65        let _ = entry.sender.send(result);
66        Some(entry.tool_name)
67    }
68    pub fn grant_key(&self, id: Uuid) -> Option<String> {
69        self.inner.lock().get(&id).map(|e| e.grant_key.clone())
70    }
71
72    /// Cancel all pending entries (e.g., on shutdown).
73    pub fn cancel_all(&self) {
74        let mut guard = self.inner.lock();
75        for (_, entry) in guard.drain() {
76            let _ = entry.sender.send(ToolApprovalResult::Denied);
77        }
78    }
79}
80#[cfg(test)]
81mod tests {
82    use super::*;
83    #[test]
84    fn grant_key_round_trip() {
85        let registry = PendingToolApprovals::new();
86        let (id, rx) = registry.register("exec".to_string(), "exec:curl".to_string());
87        assert_eq!(registry.grant_key(id), Some("exec:curl".to_string()));
88        assert_eq!(
89            registry.resolve(id, ToolApprovalResult::Approved),
90            Some("exec".to_string())
91        );
92        let result = rx.blocking_recv().expect("oneshot did not deliver");
93        assert_eq!(result, ToolApprovalResult::Approved);
94    }
95}