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