1use 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
34const APPROVAL_TIMEOUT: Duration = Duration::from_secs(120);
36
37const FILE_TOOLS: &[&str] = &["read", "write", "edit", "ls", "find", "grep"];
41
42fn extract_path_from_params(tool_name: &str, params: &Value) -> Option<String> {
44 if !FILE_TOOLS.contains(&tool_name) {
45 return None;
46 }
47
48 params
50 .get("path")
51 .and_then(|v| v.as_str())
52 .map(String::from)
53}
54
55fn path_mode_for_tool(tool_name: &str) -> PathMode {
57 match tool_name {
58 "write" | "edit" => PathMode::Write,
59 _ => PathMode::Read,
60 }
61}
62
63fn 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
79pub struct GatedTool<T: AgentTool> {
91 inner: T,
92 gate: Arc<AccessGate>,
93 context: AgentContext,
94 approval_gate: Option<Arc<ApprovalGate>>,
97 event_bus: Option<EventBus>,
99 pending_approvals: Option<Arc<PendingToolApprovals>>,
101}
102
103impl<T: AgentTool> GatedTool<T> {
104 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 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 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 if let Some(path) = extract_path_from_params(tool_name, ¶ms) {
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 if let Some(approval_gate) = &self.approval_gate {
218 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: ¶ms,
234 };
235 match approval_gate.evaluate(&call) {
236 ApprovalDecision::Allow => {
237 }
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 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 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 }
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 }
294 }
295 }
296 }
297 }
298
299 self.inner.execute(tool_call_id, params, signal, ctx).await
301 }
302}
303
304pub 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#[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", ¶ms),
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", ¶ms), 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}