Skip to main content

oxios_kernel/tools/
gated_tool.rs

1//! Gated tool registry — intercepts all tool executions through AccessGate.
2//!
3//! Instead of wrapping individual tools (which requires access to tool internals),
4//! this module provides a registry-level proxy that checks permissions before
5//! delegating to the real tool. This means:
6//!
7//! - No changes to individual tool code
8//! - New tools are automatically protected
9//! - oxi-sdk crate tools (ReadTool, WriteTool, etc.) are covered without modification
10//!
11//! RFC-035: After the structural `AccessGate` (CSpace / RBAC / Permissions /
12//! ExecConfig) passes, `GatedTool` consults the [`ApprovalGate`] (Step 2.5).
13//! On [`ApprovalDecision::Allow`] the call delegates; on
14//! [`ApprovalDecision::RequireApproval`] the tool requests a user decision via
15//! the kernel event bus, blocking until resolved (or until the 120s timeout).
16//! This is the unified mechanism that supersedes the bespoke exec-only shell
17//! approval.
18
19use async_trait::async_trait;
20use std::path::Path;
21use std::sync::Arc;
22use std::time::Duration;
23
24use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
25use serde_json::Value;
26
27use crate::access_manager::{
28    AccessDenied, AccessGate, AgentContext, CheckRequest, DenyLayer, PathMode,
29};
30use crate::approval::{ApprovalDecision, ApprovalGate, ToolCall};
31use crate::event_bus::EventBus;
32use crate::tools::{PathAccessResult, PendingPathAccess, PendingToolApprovals, ToolApprovalResult};
33
34/// Default timeout for awaiting a user approval response.
35const APPROVAL_TIMEOUT: Duration = Duration::from_secs(120);
36/// How often a blocked tool call re-checks whether a policy change now lets it
37/// auto-run (e.g. the user switched to AutoRun, or added a grant, while the
38/// approval card is still showing). Short enough to feel instant; the per-poll
39/// gate evaluation is a HashMap lookup + blacklist scan, so the cost is nil.
40const APPROVAL_REEVAL_INTERVAL: Duration = Duration::from_millis(500);
41/// Default timeout for a path-access card. Same window as tool approval.
42const PATH_ACCESS_TIMEOUT: Duration = Duration::from_secs(120);
43/// Re-eval interval for path-access cards — mirrors APPROVAL_REEVAL_INTERVAL.
44const PATH_ACCESS_REEVAL_INTERVAL: Duration = Duration::from_millis(500);
45
46// ─── Path Extraction ────────────────────────────────────────────────────────
47
48/// Tool names that perform file operations and need path checking.
49const FILE_TOOLS: &[&str] = &["read", "write", "edit", "ls", "find", "grep"];
50
51/// Extract the target path from tool parameters.
52fn extract_path_from_params(tool_name: &str, params: &Value) -> Option<String> {
53    if !FILE_TOOLS.contains(&tool_name) {
54        return None;
55    }
56
57    // Most file tools use "path" parameter
58    params
59        .get("path")
60        .and_then(|v| v.as_str())
61        .map(String::from)
62}
63
64/// Determine path access mode from tool name.
65fn path_mode_for_tool(tool_name: &str) -> PathMode {
66    match tool_name {
67        "write" | "edit" => PathMode::Write,
68        _ => PathMode::Read,
69    }
70}
71
72/// Format an access denied error for tool execution.
73fn format_denied(denied: &AccessDenied) -> String {
74    let layer_tag = match denied.layer {
75        DenyLayer::Capability => "[CSpace]",
76        DenyLayer::Rbac => "[RBAC]",
77        DenyLayer::Permission => "[Permissions]",
78        DenyLayer::ExecPolicy => "[ExecPolicy]",
79    };
80    format!(
81        "🔒 Access denied: {} — {} {}",
82        denied.reason,
83        denied.suggestion.as_deref().unwrap_or(""),
84        layer_tag
85    )
86}
87
88// ─── Gated Tool ─────────────────────────────────────────────────────────────
89
90/// A tool wrapper that checks permissions before execution.
91///
92/// Wraps any `AgentTool` and performs access control before delegating
93/// to the inner tool's `execute` method.
94///
95/// When constructed via [`GatedTool::with_approval`], the wrapper also
96/// consults the [`ApprovalGate`] (RFC-035) after the structural `AccessGate`
97/// and surfaces `RequireApproval` decisions as user prompts on the kernel
98/// event bus.
99pub struct GatedTool<T: AgentTool> {
100    inner: T,
101    gate: Arc<AccessGate>,
102    context: AgentContext,
103    /// RFC-035 approval gate. `None` ⇒ no approval logic, executable as soon
104    /// as `AccessGate` allows.
105    approval_gate: Option<Arc<ApprovalGate>>,
106    /// Kernel event bus used to publish `KernelEvent::ApprovalRequested`
107    /// and `KernelEvent::PathAccessRequested`.
108    event_bus: Option<EventBus>,
109    /// Registry of pending tool-approval decisions (RFC-035).
110    pending_approvals: Option<Arc<PendingToolApprovals>>,
111    /// Registry of pending path-access requests. When an agent tries to
112    /// read/write outside its `allowed_paths`, the denial is surfaced as
113    /// an interactive card (create Mount / temp-allow / deny) instead of
114    /// a hard error. `None` ⇒ headless, returns the error immediately.
115    pending_path_access: Option<Arc<PendingPathAccess>>,
116}
117
118impl<T: AgentTool> GatedTool<T> {
119    /// Create a new gated tool with no approval pipeline.
120    ///
121    /// Existing call sites that don't yet pass an approval gate still compile
122    /// — the approval pipeline is opted-in via [`GatedTool::with_approval`].
123    pub fn new(inner: T, gate: Arc<AccessGate>, context: AgentContext) -> Self {
124        Self {
125            inner,
126            gate,
127            context,
128            approval_gate: None,
129            event_bus: None,
130            pending_approvals: None,
131            pending_path_access: None,
132        }
133    }
134
135    /// Construct a gated tool with the RFC-035 approval pipeline wired.
136    ///
137    /// All three opt-in arguments may be `None` (legacy callers, tests,
138    /// headless paths), but production paths always supply them.
139    pub fn with_approval(
140        inner: T,
141        gate: Arc<AccessGate>,
142        context: AgentContext,
143        approval_gate: Option<Arc<ApprovalGate>>,
144        event_bus: Option<EventBus>,
145        pending_approvals: Option<Arc<PendingToolApprovals>>,
146        pending_path_access: Option<Arc<PendingPathAccess>>,
147    ) -> Self {
148        Self {
149            inner,
150            gate,
151            context,
152            approval_gate,
153            event_bus,
154            pending_approvals,
155            pending_path_access,
156        }
157    }
158}
159
160impl<T: AgentTool> std::fmt::Debug for GatedTool<T> {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.debug_struct("GatedTool")
163            .field("name", &self.inner.name())
164            .finish()
165    }
166}
167
168#[async_trait]
169impl<T: AgentTool + 'static> AgentTool for GatedTool<T> {
170    fn name(&self) -> &str {
171        self.inner.name()
172    }
173
174    fn label(&self) -> &str {
175        self.inner.label()
176    }
177
178    fn description(&self) -> &'static str {
179        "Execute commands and access system resources. Permissions enforced by AccessGate."
180    }
181
182    fn parameters_schema(&self) -> Value {
183        self.inner.parameters_schema()
184    }
185
186    async fn execute(
187        &self,
188        tool_call_id: &str,
189        params: Value,
190        signal: Option<tokio::sync::oneshot::Receiver<()>>,
191        ctx: &ToolContext,
192    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
193        let tool_name = self.inner.name();
194
195        // Step 1: Check tool access permission (CSpace / RBAC / Permissions / ExecConfig).
196        let check = CheckRequest::Tool {
197            context: &self.context,
198            tool_name,
199        };
200        if let Err(denied) = self.gate.check(check) {
201            tracing::warn!(
202                agent = %denied.agent,
203                tool = %tool_name,
204                layer = ?denied.layer,
205                "GatedTool: tool access denied"
206            );
207            return Ok(AgentToolResult::error(format_denied(&denied)));
208        }
209
210        // Step 2: For file tools, check path access permission. On denial,
211        // surface an interactive path-access card (create Mount / temp-allow /
212        // deny) when the event bus and path-access registry are wired;
213        // otherwise return the hard error (headless / test path).
214        if let Some(path) = extract_path_from_params(tool_name, &params) {
215            let mode = path_mode_for_tool(tool_name);
216            let path_check = CheckRequest::Path {
217                context: &self.context,
218                path: Path::new(&path),
219                mode,
220            };
221            if let Err(denied) = self.gate.check(path_check) {
222                tracing::warn!(
223                    agent = %denied.agent,
224                    path = %path,
225                    tool = %tool_name,
226                    layer = ?denied.layer,
227                    "GatedTool: path access denied"
228                );
229                match (self.event_bus.as_ref(), self.pending_path_access.as_ref()) {
230                    (Some(bus), Some(registry)) => {
231                        let mode_str = match mode {
232                            PathMode::Read => "read",
233                            PathMode::Write => "write",
234                        };
235                        let (request_id, rx) = registry.register(
236                            tool_name.to_string(),
237                            path.clone(),
238                            mode_str.to_string(),
239                            self.context.agent_name.clone(),
240                        );
241                        let _ = bus.publish(crate::event_bus::KernelEvent::PathAccessRequested {
242                            id: request_id,
243                            tool_name: tool_name.to_string(),
244                            path: path.chars().take(200).collect(),
245                            mode: mode_str.to_string(),
246                            agent_name: self.context.agent_name.clone(),
247                            reason: denied.reason.clone(),
248                            session_id: None,
249                        });
250                        tracing::info!(
251                            request_id = %request_id,
252                            path = %path,
253                            tool = %tool_name,
254                            "path access requested — awaiting user decision"
255                        );
256                        let deadline = tokio::time::Instant::now() + PATH_ACCESS_TIMEOUT;
257                        tokio::pin!(rx);
258                        let allowed = loop {
259                            tokio::select! {
260                                biased;
261                                res = &mut rx => match res {
262                                    Ok(PathAccessResult::Allowed) => break true,
263                                    _ => break false,
264                                },
265                                _ = tokio::time::sleep_until(deadline) => {
266                                    let _ = registry
267                                        .resolve(request_id, PathAccessResult::Denied);
268                                    break false;
269                                }
270                                _ = tokio::time::sleep(PATH_ACCESS_REEVAL_INTERVAL) => {
271                                    if self.gate.check(CheckRequest::Path {
272                                        context: &self.context,
273                                        path: Path::new(&path),
274                                        mode,
275                                    }).is_ok() {
276                                        let _ = registry
277                                            .resolve(request_id, PathAccessResult::Allowed);
278                                        break true;
279                                    }
280                                }
281                            }
282                        };
283                        if !allowed {
284                            return Ok(AgentToolResult::error(format!(
285                                "🔒 Path access denied: {}",
286                                denied.reason
287                            )));
288                        }
289                        // Path now granted — fall through to Step 2.5.
290                    }
291                    _ => {
292                        // Headless: no event bus / registry — hard deny.
293                        return Ok(AgentToolResult::error(format!(
294                            "🔒 Path access denied: {}",
295                            denied.reason
296                        )));
297                    }
298                }
299            }
300        }
301
302        // Step 2.5 (RFC-035): ApprovalGate evaluation — decides whether the
303        // call auto-runs or surfaces a human-in-the-loop approval card.
304        if let Some(approval_gate) = &self.approval_gate {
305            // `binary` is the binary field ONLY (RFC-035 Task 14). The
306            // `command` field is the full shell string and would pollute
307            // `grant_key()` (which uses `binary`) in allow-list mode. The
308            // approval card's display `resource` derives a richer string
309            // from `command` separately (below), but `ToolCall.binary`
310            // must stay binary-only so `exec:<binary>` / `exec:shell`
311            // grant keys are correct.
312            let binary = if tool_name == "exec" {
313                params.get("binary").and_then(|v| v.as_str())
314            } else {
315                None
316            };
317            let call = ToolCall {
318                tool: tool_name,
319                binary,
320                args: &params,
321            };
322            match approval_gate.evaluate(&call) {
323                ApprovalDecision::Allow => {
324                    // fall through to Step 3
325                }
326                ApprovalDecision::RequireApproval { reason } => {
327                    match (self.event_bus.as_ref(), self.pending_approvals.as_ref()) {
328                        (Some(bus), Some(pending)) => {
329                            let approvals = pending.clone();
330                            let (approval_id, rx) =
331                                approvals.register(tool_name.to_string(), call.grant_key());
332                            let action = format!("tool:{tool_name}");
333                            // Resource shown on the approval card. Prefer the
334                            // shell `command` (most informative), fall back to
335                            // the structured `binary`, then the bare tool name.
336                            // Independent of `binary` because shell exec has
337                            // `binary = None` after the Task 14 overload fix.
338                            let resource = params
339                                .get("command")
340                                .and_then(|v| v.as_str())
341                                .map(String::from)
342                                .or_else(|| binary.map(String::from))
343                                .unwrap_or_else(|| tool_name.to_string());
344                            // Publish using the exact KernelEvent::ApprovalRequested
345                            // field shape (event_bus.rs:83-96). The frontend uses
346                            // this to render the approval card.
347                            let _ = bus.publish(crate::event_bus::KernelEvent::ApprovalRequested {
348                                id: approval_id,
349                                tool_name: tool_name.to_string(),
350                                action,
351                                resource: resource.chars().take(200).collect(),
352                                reason: reason.clone(),
353                                session_id: None,
354                            });
355                            // Await the user's decision, but periodically
356                            // re-evaluate the gate. Without re-evaluation a
357                            // policy change made while the card is shown
358                            // (switching to AutoRun, or granting the tool)
359                            // would strand the call — the oneshot only fires
360                            // on an explicit click, so the agent would freeze
361                            // until the 120s timeout. Polling the SAME gate
362                            // that issued the card means the live policy —
363                            // dynamic resolvers and the security blacklist
364                            // included — is applied exactly as a fresh call.
365                            let deadline = tokio::time::Instant::now() + APPROVAL_TIMEOUT;
366                            tokio::pin!(rx);
367                            let approved = loop {
368                                tokio::select! {
369                                    biased;
370                                    // Explicit user decision wins immediately.
371                                    res = &mut rx => match res {
372                                        Ok(ToolApprovalResult::Approved) => {
373                                            tracing::info!(
374                                                approval_id = %approval_id,
375                                                tool = %tool_name,
376                                                "tool call approved by user"
377                                            );
378                                            break true;
379                                        }
380                                        _ => {
381                                            let _ = approvals
382                                                .resolve(approval_id, ToolApprovalResult::Denied);
383                                            break false;
384                                        }
385                                    },
386                                    // Hard timeout — deny and surface an error.
387                                    _ = tokio::time::sleep_until(deadline) => {
388                                        let _ = approvals
389                                            .resolve(approval_id, ToolApprovalResult::Denied);
390                                        break false;
391                                    }
392                                    // Re-evaluate under the live config; if the
393                                    // call would now auto-run, approve ourselves.
394                                    _ = tokio::time::sleep(APPROVAL_REEVAL_INTERVAL) => {
395                                        if matches!(
396                                            approval_gate.evaluate(&call),
397                                            ApprovalDecision::Allow
398                                        ) {
399                                            tracing::info!(
400                                                approval_id = %approval_id,
401                                                tool = %tool_name,
402                                                "tool call auto-approved after policy change"
403                                            );
404                                            let _ = approvals
405                                                .resolve(approval_id, ToolApprovalResult::Approved);
406                                            break true;
407                                        }
408                                        // Policy still requires approval — poll again.
409                                    }
410                                }
411                            };
412                            if !approved {
413                                return Ok(AgentToolResult::error(format!(
414                                    "Tool execution was denied or timed out ({}s).",
415                                    APPROVAL_TIMEOUT.as_secs()
416                                )));
417                            }
418                            // fall through to Step 3
419                        }
420                        _ => {
421                            tracing::warn!(
422                                tool = %tool_name,
423                                "ApprovalGate requires approval but no event bus / pending approvals \
424                                 wired — allowing (headless path, tool would deadlock)"
425                            );
426                            // fall through to Step 3
427                        }
428                    }
429                }
430            }
431        }
432
433        // Step 3: Access and (RFC-035) approval both granted — delegate.
434        self.inner.execute(tool_call_id, params, signal, ctx).await
435    }
436}
437
438/// Wrap a tool with access control.
439///
440/// Convenience function for creating `GatedTool` instances.
441pub fn gate_tool<T: AgentTool + 'static>(
442    tool: T,
443    gate: Arc<AccessGate>,
444    context: AgentContext,
445) -> GatedTool<T> {
446    GatedTool::new(tool, gate, context)
447}
448
449// ─── Tests ──────────────────────────────────────────────────────────────────
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::access_manager::{AccessManager, AgentPermissions, NoOpAuditSink, Role, Subject};
455    use crate::config::ExecConfig;
456    use oxi_sdk::ReadTool;
457    use parking_lot::Mutex;
458
459    fn make_gate_for_test() -> Arc<AccessGate> {
460        let mut access = AccessManager::new();
461        let perms = AgentPermissions::for_new_agent("test-agent");
462        access.set_permissions(perms);
463
464        let subject = Subject::Agent(uuid::Uuid::new_v4());
465        access
466            .rbac_manager_mut()
467            .assign_role(subject, Role::Superuser);
468
469        Arc::new(AccessGate::new(
470            Arc::new(Mutex::new(access)),
471            Arc::new(ExecConfig::default()),
472            Arc::new(NoOpAuditSink),
473        ))
474    }
475
476    #[test]
477    fn test_gated_tool_preserves_name() {
478        let gate = make_gate_for_test();
479        let ctx = AgentContext::test_fixture("test-agent");
480        let tool = GatedTool::new(ReadTool::new(), gate, ctx);
481        assert_eq!(tool.name(), "read");
482    }
483
484    #[test]
485    fn test_extract_path_read_tool() {
486        let params = serde_json::json!({"path": "/workspace/file.rs"});
487        assert_eq!(
488            extract_path_from_params("read", &params),
489            Some("/workspace/file.rs".to_string())
490        );
491    }
492
493    #[test]
494    fn test_extract_path_exec_tool() {
495        let params = serde_json::json!({"command": "echo hello"});
496        assert_eq!(extract_path_from_params("exec", &params), None);
497    }
498
499    #[test]
500    fn test_path_mode_for_tool() {
501        assert_eq!(path_mode_for_tool("write"), PathMode::Write);
502        assert_eq!(path_mode_for_tool("edit"), PathMode::Write);
503        assert_eq!(path_mode_for_tool("read"), PathMode::Read);
504        assert_eq!(path_mode_for_tool("ls"), PathMode::Read);
505    }
506
507    #[test]
508    fn test_format_denied() {
509        let denied = AccessDenied {
510            agent: "test".into(),
511            resource: "exec".into(),
512            layer: DenyLayer::ExecPolicy,
513            reason: "not in allowlist".into(),
514            suggestion: Some("add to config".into()),
515        };
516        let s = format_denied(&denied);
517        assert!(s.contains("🔒"));
518        assert!(s.contains("[ExecPolicy]"));
519    }
520}