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, ¶ms) {
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: ¶ms,
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", ¶ms),
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", ¶ms), 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}