1use 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
22const FILE_TOOLS: &[&str] = &["read", "write", "edit", "ls", "find", "grep"];
26
27fn extract_path_from_params(tool_name: &str, params: &Value) -> Option<String> {
29 if !FILE_TOOLS.contains(&tool_name) {
30 return None;
31 }
32
33 params
35 .get("path")
36 .and_then(|v| v.as_str())
37 .map(String::from)
38}
39
40fn path_mode_for_tool(tool_name: &str) -> PathMode {
42 match tool_name {
43 "write" | "edit" => PathMode::Write,
44 _ => PathMode::Read,
45 }
46}
47
48fn 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
64pub struct GatedTool<T: AgentTool> {
71 inner: T,
72 gate: Arc<AccessGate>,
73 context: AgentContext,
74}
75
76impl<T: AgentTool> GatedTool<T> {
77 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 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 if let Some(path) = extract_path_from_params(tool_name, ¶ms) {
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 self.inner.execute(tool_call_id, params, signal, ctx).await
164 }
165}
166
167pub 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#[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", ¶ms),
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", ¶ms), 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}