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
11use async_trait::async_trait;
12use std::path::Path;
13use std::sync::Arc;
14
15use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
16use serde_json::Value;
17
18use crate::access_manager::{
19    AccessDenied, AccessGate, AgentContext, CheckRequest, DenyLayer, PathMode,
20};
21
22// ─── Path Extraction ────────────────────────────────────────────────────────
23
24/// Tool names that perform file operations and need path checking.
25const FILE_TOOLS: &[&str] = &["read", "write", "edit", "ls", "find", "grep"];
26
27/// Extract the target path from tool parameters.
28fn extract_path_from_params(tool_name: &str, params: &Value) -> Option<String> {
29    if !FILE_TOOLS.contains(&tool_name) {
30        return None;
31    }
32
33    // Most file tools use "path" parameter
34    params
35        .get("path")
36        .and_then(|v| v.as_str())
37        .map(String::from)
38}
39
40/// Determine path access mode from tool name.
41fn path_mode_for_tool(tool_name: &str) -> PathMode {
42    match tool_name {
43        "write" | "edit" => PathMode::Write,
44        _ => PathMode::Read,
45    }
46}
47
48/// Format an access denied error for tool execution.
49fn format_denied(denied: &AccessDenied) -> String {
50    let layer_tag = match denied.layer {
51        DenyLayer::Capability => "[CSpace]",
52        DenyLayer::Rbac => "[RBAC]",
53        DenyLayer::Permission => "[Permissions]",
54        DenyLayer::ExecPolicy => "[ExecPolicy]",
55    };
56    format!(
57        "🔒 권한 거부: {} — {} {}",
58        denied.reason,
59        denied.suggestion.as_deref().unwrap_or(""),
60        layer_tag
61    )
62}
63
64// ─── Gated Tool ─────────────────────────────────────────────────────────────
65
66/// A tool wrapper that checks permissions before execution.
67///
68/// Wraps any `AgentTool` and performs access control before delegating
69/// to the inner tool's `execute` method.
70pub struct GatedTool<T: AgentTool> {
71    inner: T,
72    gate: Arc<AccessGate>,
73    context: AgentContext,
74}
75
76impl<T: AgentTool> GatedTool<T> {
77    /// Create a new gated tool wrapping the given tool.
78    pub fn new(inner: T, gate: Arc<AccessGate>, context: AgentContext) -> Self {
79        Self {
80            inner,
81            gate,
82            context,
83        }
84    }
85}
86
87impl<T: AgentTool> std::fmt::Debug for GatedTool<T> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("GatedTool")
90            .field("name", &self.inner.name())
91            .finish()
92    }
93}
94
95#[async_trait]
96impl<T: AgentTool + 'static> AgentTool for GatedTool<T> {
97    fn name(&self) -> &str {
98        self.inner.name()
99    }
100
101    fn label(&self) -> &str {
102        self.inner.label()
103    }
104
105    fn description(&self) -> &'static str {
106        "Execute commands and access system resources. Permissions enforced by AccessGate."
107    }
108
109    fn parameters_schema(&self) -> Value {
110        self.inner.parameters_schema()
111    }
112
113    async fn execute(
114        &self,
115        tool_call_id: &str,
116        params: Value,
117        signal: Option<tokio::sync::oneshot::Receiver<()>>,
118        ctx: &ToolContext,
119    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
120        let tool_name = self.inner.name();
121
122        // Step 1: Check tool access permission
123        let check = CheckRequest::Tool {
124            context: &self.context,
125            tool_name,
126        };
127
128        if let Err(denied) = self.gate.check(check) {
129            tracing::warn!(
130                agent = %denied.agent,
131                tool = %tool_name,
132                layer = ?denied.layer,
133                "GatedTool: tool access denied"
134            );
135            return Ok(AgentToolResult::error(format_denied(&denied)));
136        }
137
138        // Step 2: For file tools, check path access permission
139        if let Some(path) = extract_path_from_params(tool_name, &params) {
140            let mode = path_mode_for_tool(tool_name);
141            let path_check = CheckRequest::Path {
142                context: &self.context,
143                path: Path::new(&path),
144                mode,
145            };
146
147            if let Err(denied) = self.gate.check(path_check) {
148                tracing::warn!(
149                    agent = %denied.agent,
150                    path = %path,
151                    tool = %tool_name,
152                    layer = ?denied.layer,
153                    "GatedTool: path access denied"
154                );
155                return Ok(AgentToolResult::error(format!(
156                    "🔒 경로 접근 거부: {}",
157                    denied.reason
158                )));
159            }
160        }
161
162        // Step 3: Permission granted — delegate to inner tool
163        self.inner.execute(tool_call_id, params, signal, ctx).await
164    }
165}
166
167/// Wrap a tool with access control.
168///
169/// Convenience function for creating `GatedTool` instances.
170pub fn gate_tool<T: AgentTool + 'static>(
171    tool: T,
172    gate: Arc<AccessGate>,
173    context: AgentContext,
174) -> GatedTool<T> {
175    GatedTool::new(tool, gate, context)
176}
177
178// ─── Tests ──────────────────────────────────────────────────────────────────
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::access_manager::{AccessManager, AgentPermissions, NoOpAuditSink, Role, Subject};
184    use crate::config::ExecConfig;
185    use oxi_sdk::ReadTool;
186    use parking_lot::Mutex;
187
188    fn make_gate_for_test() -> Arc<AccessGate> {
189        let mut access = AccessManager::new();
190        let perms = AgentPermissions::for_new_agent("test-agent");
191        access.set_permissions(perms);
192
193        let subject = Subject::Agent(
194            <crate::types::AgentId as std::convert::From<uuid::Uuid>>::from(uuid::Uuid::new_v4()),
195        );
196        access
197            .rbac_manager_mut()
198            .assign_role(subject, Role::Superuser);
199
200        Arc::new(AccessGate::new(
201            Arc::new(Mutex::new(access)),
202            Arc::new(ExecConfig::default()),
203            Arc::new(NoOpAuditSink),
204        ))
205    }
206
207    #[test]
208    fn test_gated_tool_preserves_name() {
209        let gate = make_gate_for_test();
210        let ctx = AgentContext::test_fixture("test-agent");
211        let tool = GatedTool::new(ReadTool::new(), gate, ctx);
212        assert_eq!(tool.name(), "read");
213    }
214
215    #[test]
216    fn test_extract_path_read_tool() {
217        let params = serde_json::json!({"path": "/workspace/file.rs"});
218        assert_eq!(
219            extract_path_from_params("read", &params),
220            Some("/workspace/file.rs".to_string())
221        );
222    }
223
224    #[test]
225    fn test_extract_path_exec_tool() {
226        let params = serde_json::json!({"command": "echo hello"});
227        assert_eq!(extract_path_from_params("exec", &params), None);
228    }
229
230    #[test]
231    fn test_path_mode_for_tool() {
232        assert_eq!(path_mode_for_tool("write"), PathMode::Write);
233        assert_eq!(path_mode_for_tool("edit"), PathMode::Write);
234        assert_eq!(path_mode_for_tool("read"), PathMode::Read);
235        assert_eq!(path_mode_for_tool("ls"), PathMode::Read);
236    }
237
238    #[test]
239    fn test_format_denied() {
240        let denied = AccessDenied {
241            agent: "test".into(),
242            resource: "exec".into(),
243            layer: DenyLayer::ExecPolicy,
244            reason: "not in allowlist".into(),
245            suggestion: Some("add to config".into()),
246        };
247        let s = format_denied(&denied);
248        assert!(s.contains("🔒"));
249        assert!(s.contains("[ExecPolicy]"));
250    }
251}