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::{PendingToolApprovals, ToolApprovalResult};
33
34/// Default timeout for awaiting a user approval response.
35const APPROVAL_TIMEOUT: Duration = Duration::from_secs(120);
36
37// ─── Path Extraction ────────────────────────────────────────────────────────
38
39/// Tool names that perform file operations and need path checking.
40const FILE_TOOLS: &[&str] = &["read", "write", "edit", "ls", "find", "grep"];
41
42/// Extract the target path from tool parameters.
43fn extract_path_from_params(tool_name: &str, params: &Value) -> Option<String> {
44    if !FILE_TOOLS.contains(&tool_name) {
45        return None;
46    }
47
48    // Most file tools use "path" parameter
49    params
50        .get("path")
51        .and_then(|v| v.as_str())
52        .map(String::from)
53}
54
55/// Determine path access mode from tool name.
56fn path_mode_for_tool(tool_name: &str) -> PathMode {
57    match tool_name {
58        "write" | "edit" => PathMode::Write,
59        _ => PathMode::Read,
60    }
61}
62
63/// Format an access denied error for tool execution.
64fn format_denied(denied: &AccessDenied) -> String {
65    let layer_tag = match denied.layer {
66        DenyLayer::Capability => "[CSpace]",
67        DenyLayer::Rbac => "[RBAC]",
68        DenyLayer::Permission => "[Permissions]",
69        DenyLayer::ExecPolicy => "[ExecPolicy]",
70    };
71    format!(
72        "🔒 Access denied: {} — {} {}",
73        denied.reason,
74        denied.suggestion.as_deref().unwrap_or(""),
75        layer_tag
76    )
77}
78
79// ─── Gated Tool ─────────────────────────────────────────────────────────────
80
81/// A tool wrapper that checks permissions before execution.
82///
83/// Wraps any `AgentTool` and performs access control before delegating
84/// to the inner tool's `execute` method.
85///
86/// When constructed via [`GatedTool::with_approval`], the wrapper also
87/// consults the [`ApprovalGate`] (RFC-035) after the structural `AccessGate`
88/// and surfaces `RequireApproval` decisions as user prompts on the kernel
89/// event bus.
90pub struct GatedTool<T: AgentTool> {
91    inner: T,
92    gate: Arc<AccessGate>,
93    context: AgentContext,
94    /// RFC-035 approval gate. `None` ⇒ no approval logic, executable as soon
95    /// as `AccessGate` allows.
96    approval_gate: Option<Arc<ApprovalGate>>,
97    /// Kernel event bus used to publish `KernelEvent::ApprovalRequested`.
98    event_bus: Option<EventBus>,
99    /// Registry of pending user decisions to resolve after a prompt.
100    pending_approvals: Option<Arc<PendingToolApprovals>>,
101}
102
103impl<T: AgentTool> GatedTool<T> {
104    /// Create a new gated tool with no approval pipeline.
105    ///
106    /// Existing call sites that don't yet pass an approval gate still compile
107    /// — the approval pipeline is opted-in via [`GatedTool::with_approval`].
108    pub fn new(inner: T, gate: Arc<AccessGate>, context: AgentContext) -> Self {
109        Self {
110            inner,
111            gate,
112            context,
113            approval_gate: None,
114            event_bus: None,
115            pending_approvals: None,
116        }
117    }
118
119    /// Construct a gated tool with the RFC-035 approval pipeline wired.
120    ///
121    /// All three opt-in arguments may be `None` (legacy callers, tests,
122    /// headless paths), but production paths always supply them.
123    pub fn with_approval(
124        inner: T,
125        gate: Arc<AccessGate>,
126        context: AgentContext,
127        approval_gate: Option<Arc<ApprovalGate>>,
128        event_bus: Option<EventBus>,
129        pending_approvals: Option<Arc<PendingToolApprovals>>,
130    ) -> Self {
131        Self {
132            inner,
133            gate,
134            context,
135            approval_gate,
136            event_bus,
137            pending_approvals,
138        }
139    }
140}
141
142impl<T: AgentTool> std::fmt::Debug for GatedTool<T> {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("GatedTool")
145            .field("name", &self.inner.name())
146            .finish()
147    }
148}
149
150#[async_trait]
151impl<T: AgentTool + 'static> AgentTool for GatedTool<T> {
152    fn name(&self) -> &str {
153        self.inner.name()
154    }
155
156    fn label(&self) -> &str {
157        self.inner.label()
158    }
159
160    fn description(&self) -> &'static str {
161        "Execute commands and access system resources. Permissions enforced by AccessGate."
162    }
163
164    fn parameters_schema(&self) -> Value {
165        self.inner.parameters_schema()
166    }
167
168    async fn execute(
169        &self,
170        tool_call_id: &str,
171        params: Value,
172        signal: Option<tokio::sync::oneshot::Receiver<()>>,
173        ctx: &ToolContext,
174    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
175        let tool_name = self.inner.name();
176
177        // Step 1: Check tool access permission (CSpace / RBAC / Permissions / ExecConfig).
178        let check = CheckRequest::Tool {
179            context: &self.context,
180            tool_name,
181        };
182        if let Err(denied) = self.gate.check(check) {
183            tracing::warn!(
184                agent = %denied.agent,
185                tool = %tool_name,
186                layer = ?denied.layer,
187                "GatedTool: tool access denied"
188            );
189            return Ok(AgentToolResult::error(format_denied(&denied)));
190        }
191
192        // Step 2: For file tools, check path access permission.
193        if let Some(path) = extract_path_from_params(tool_name, &params) {
194            let mode = path_mode_for_tool(tool_name);
195            let path_check = CheckRequest::Path {
196                context: &self.context,
197                path: Path::new(&path),
198                mode,
199            };
200            if let Err(denied) = self.gate.check(path_check) {
201                tracing::warn!(
202                    agent = %denied.agent,
203                    path = %path,
204                    tool = %tool_name,
205                    layer = ?denied.layer,
206                    "GatedTool: path access denied"
207                );
208                return Ok(AgentToolResult::error(format!(
209                    "🔒 Path access denied: {}",
210                    denied.reason
211                )));
212            }
213        }
214
215        // Step 2.5 (RFC-035): ApprovalGate evaluation — decides whether the
216        // call auto-runs or surfaces a human-in-the-loop approval card.
217        if let Some(approval_gate) = &self.approval_gate {
218            // `binary` is the binary field ONLY (RFC-035 Task 14). The
219            // `command` field is the full shell string and would pollute
220            // `grant_key()` (which uses `binary`) in allow-list mode. The
221            // approval card's display `resource` derives a richer string
222            // from `command` separately (below), but `ToolCall.binary`
223            // must stay binary-only so `exec:<binary>` / `exec:shell`
224            // grant keys are correct.
225            let binary = if tool_name == "exec" {
226                params.get("binary").and_then(|v| v.as_str())
227            } else {
228                None
229            };
230            let call = ToolCall {
231                tool: tool_name,
232                binary,
233                args: &params,
234            };
235            match approval_gate.evaluate(&call) {
236                ApprovalDecision::Allow => {
237                    // fall through to Step 3
238                }
239                ApprovalDecision::RequireApproval { reason } => {
240                    match (self.event_bus.as_ref(), self.pending_approvals.as_ref()) {
241                        (Some(bus), Some(pending)) => {
242                            let approvals = pending.clone();
243                            let (approval_id, rx) =
244                                approvals.register(tool_name.to_string(), call.grant_key());
245                            let action = format!("tool:{tool_name}");
246                            // Resource shown on the approval card. Prefer the
247                            // shell `command` (most informative), fall back to
248                            // the structured `binary`, then the bare tool name.
249                            // Independent of `binary` because shell exec has
250                            // `binary = None` after the Task 14 overload fix.
251                            let resource = params
252                                .get("command")
253                                .and_then(|v| v.as_str())
254                                .map(String::from)
255                                .or_else(|| binary.map(String::from))
256                                .unwrap_or_else(|| tool_name.to_string());
257                            // Publish using the exact KernelEvent::ApprovalRequested
258                            // field shape (event_bus.rs:83-96). The frontend uses
259                            // this to render the approval card.
260                            let _ = bus.publish(crate::event_bus::KernelEvent::ApprovalRequested {
261                                id: approval_id,
262                                tool_name: tool_name.to_string(),
263                                action,
264                                resource: resource.chars().take(200).collect(),
265                                reason: reason.clone(),
266                                session_id: None,
267                            });
268                            match tokio::time::timeout(APPROVAL_TIMEOUT, rx).await {
269                                Ok(Ok(ToolApprovalResult::Approved)) => {
270                                    tracing::info!(
271                                        approval_id = %approval_id,
272                                        tool = %tool_name,
273                                        "tool call approved by user"
274                                    );
275                                    // fall through to Step 3
276                                }
277                                _ => {
278                                    let _ = approvals.resolve(approval_id, ToolApprovalResult::Denied);
279                                    return Ok(AgentToolResult::error(format!(
280                                        "Tool execution was denied or timed out ({}s).",
281                                        APPROVAL_TIMEOUT.as_secs()
282                                    )));
283                                }
284                            }
285                        }
286                        _ => {
287                            tracing::warn!(
288                                tool = %tool_name,
289                                "ApprovalGate requires approval but no event bus / pending approvals \
290                                 wired — allowing (headless path, tool would deadlock)"
291                            );
292                            // fall through to Step 3
293                        }
294                    }
295                }
296            }
297        }
298
299        // Step 3: Access and (RFC-035) approval both granted — delegate.
300        self.inner.execute(tool_call_id, params, signal, ctx).await
301    }
302}
303
304/// Wrap a tool with access control.
305///
306/// Convenience function for creating `GatedTool` instances.
307pub fn gate_tool<T: AgentTool + 'static>(
308    tool: T,
309    gate: Arc<AccessGate>,
310    context: AgentContext,
311) -> GatedTool<T> {
312    GatedTool::new(tool, gate, context)
313}
314
315// ─── Tests ──────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::access_manager::{AccessManager, AgentPermissions, NoOpAuditSink, Role, Subject};
321    use crate::config::ExecConfig;
322    use oxi_sdk::ReadTool;
323    use parking_lot::Mutex;
324
325    fn make_gate_for_test() -> Arc<AccessGate> {
326        let mut access = AccessManager::new();
327        let perms = AgentPermissions::for_new_agent("test-agent");
328        access.set_permissions(perms);
329
330        let subject = Subject::Agent(uuid::Uuid::new_v4());
331        access
332            .rbac_manager_mut()
333            .assign_role(subject, Role::Superuser);
334
335        Arc::new(AccessGate::new(
336            Arc::new(Mutex::new(access)),
337            Arc::new(ExecConfig::default()),
338            Arc::new(NoOpAuditSink),
339        ))
340    }
341
342    #[test]
343    fn test_gated_tool_preserves_name() {
344        let gate = make_gate_for_test();
345        let ctx = AgentContext::test_fixture("test-agent");
346        let tool = GatedTool::new(ReadTool::new(), gate, ctx);
347        assert_eq!(tool.name(), "read");
348    }
349
350    #[test]
351    fn test_extract_path_read_tool() {
352        let params = serde_json::json!({"path": "/workspace/file.rs"});
353        assert_eq!(
354            extract_path_from_params("read", &params),
355            Some("/workspace/file.rs".to_string())
356        );
357    }
358
359    #[test]
360    fn test_extract_path_exec_tool() {
361        let params = serde_json::json!({"command": "echo hello"});
362        assert_eq!(extract_path_from_params("exec", &params), None);
363    }
364
365    #[test]
366    fn test_path_mode_for_tool() {
367        assert_eq!(path_mode_for_tool("write"), PathMode::Write);
368        assert_eq!(path_mode_for_tool("edit"), PathMode::Write);
369        assert_eq!(path_mode_for_tool("read"), PathMode::Read);
370        assert_eq!(path_mode_for_tool("ls"), PathMode::Read);
371    }
372
373    #[test]
374    fn test_format_denied() {
375        let denied = AccessDenied {
376            agent: "test".into(),
377            resource: "exec".into(),
378            layer: DenyLayer::ExecPolicy,
379            reason: "not in allowlist".into(),
380            suggestion: Some("add to config".into()),
381        };
382        let s = format_denied(&denied);
383        assert!(s.contains("🔒"));
384        assert!(s.contains("[ExecPolicy]"));
385    }
386}