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