Skip to main content

oxios_kernel/tools/
pending_path_access.rs

1//! Pending path-access requests — interactive escalation when an agent tries
2//! to read/write a path outside its `allowed_paths`.
3//!
4//! Mirrors [`PendingToolApprovals`] but carries path-specific context
5//! (path, agent_name, mode) so the resolve endpoint can create a Mount or
6//! add a temporary `allowed_paths` entry before unblocking the GatedTool.
7//!
8//! Pattern: identical to `pending_tool_approvals.rs` (RFC-017 / RFC-035).
9
10use parking_lot::Mutex;
11use std::collections::HashMap;
12use tokio::sync::oneshot;
13use uuid::Uuid;
14
15/// Result of a path-access request.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PathAccessResult {
18    /// Path was granted (Mount created or temp-allowed) — re-check and proceed.
19    Allowed,
20    /// User declined — return the original denial error to the agent.
21    Denied,
22}
23
24struct PathAccessEntry {
25    /// The tool that tried to access the path (read, write …).
26    tool_name: String,
27    /// The denied absolute path.
28    path: String,
29    /// Access mode: "read" or "write".
30    #[allow(dead_code)]
31    mode: String,
32    /// The agent whose `allowed_paths` must be updated.
33    agent_name: String,
34    sender: oneshot::Sender<PathAccessResult>,
35}
36
37/// Thread-safe registry of in-flight path-access requests.
38#[derive(Default)]
39pub struct PendingPathAccess {
40    inner: Mutex<HashMap<Uuid, PathAccessEntry>>,
41}
42
43impl PendingPathAccess {
44    /// Create a new empty registry.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Register a new pending path-access request.
50    /// Returns the request ID and a receiver to await the user's decision.
51    pub fn register(
52        &self,
53        tool_name: String,
54        path: String,
55        mode: String,
56        agent_name: String,
57    ) -> (Uuid, oneshot::Receiver<PathAccessResult>) {
58        let id = Uuid::new_v4();
59        let (tx, rx) = oneshot::channel();
60        self.inner.lock().insert(
61            id,
62            PathAccessEntry {
63                tool_name,
64                path,
65                mode,
66                agent_name,
67                sender: tx,
68            },
69        );
70        (id, rx)
71    }
72
73    /// Resolve a pending request with the user's decision.
74    /// Returns `(tool_name, path, agent_name)` if the entry existed.
75    pub fn resolve(&self, id: Uuid, result: PathAccessResult) -> Option<(String, String, String)> {
76        let entry = self.inner.lock().remove(&id)?;
77        let _ = entry.sender.send(result);
78        Some((entry.tool_name, entry.path, entry.agent_name))
79    }
80
81    /// Look up the path context for a pending request without resolving it.
82    /// Returns `(agent_name, path)` — used by the respond endpoint to know
83    /// which agent's `allowed_paths` to update before resolving.
84    pub fn path_context(&self, id: Uuid) -> Option<(String, String)> {
85        let guard = self.inner.lock();
86        let entry = guard.get(&id)?;
87        Some((entry.agent_name.clone(), entry.path.clone()))
88    }
89
90    /// Cancel all pending entries (e.g., on shutdown).
91    pub fn cancel_all(&self) {
92        let mut guard = self.inner.lock();
93        for (_, entry) in guard.drain() {
94            let _ = entry.sender.send(PathAccessResult::Denied);
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn register_and_resolve_round_trip() {
105        let registry = PendingPathAccess::new();
106        let (id, rx) = registry.register(
107            "read".to_string(),
108            "/secret/file".to_string(),
109            "read".to_string(),
110            "agent-1".to_string(),
111        );
112        assert_eq!(
113            registry.path_context(id),
114            Some(("agent-1".to_string(), "/secret/file".to_string()))
115        );
116        let resolved = registry.resolve(id, PathAccessResult::Allowed);
117        assert_eq!(
118            resolved,
119            Some((
120                "read".to_string(),
121                "/secret/file".to_string(),
122                "agent-1".to_string()
123            ))
124        );
125        let result = rx.blocking_recv().expect("oneshot did not deliver");
126        assert_eq!(result, PathAccessResult::Allowed);
127    }
128
129    #[test]
130    fn resolve_missing_returns_none() {
131        let registry = PendingPathAccess::new();
132        let missing = Uuid::new_v4();
133        assert!(
134            registry
135                .resolve(missing, PathAccessResult::Denied)
136                .is_none()
137        );
138    }
139}