oxicode_sdk/security/
middleware.rs1use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::middleware::{
8 Middleware, MiddlewareContext, MiddlewareData, MiddlewarePhase, MiddlewareResult,
9};
10use crate::security::authorizer::Authorizer;
11use crate::security::capability::{Capability, CapabilitySubject};
12
13pub struct SecurityMiddleware {
20 authorizer: Arc<Authorizer>,
21}
22
23impl SecurityMiddleware {
24 pub fn new(authorizer: Arc<Authorizer>) -> Self {
26 Self { authorizer }
27 }
28
29 pub fn required_capability(tool_name: &str, params: &serde_json::Value) -> Option<Capability> {
31 match tool_name {
32 "read" => params
33 .get("path")
34 .and_then(|v| v.as_str())
35 .map(|p| Capability::FileRead {
36 path_pattern: p.to_string(),
37 }),
38 "write" => params
39 .get("path")
40 .and_then(|v| v.as_str())
41 .map(|p| Capability::FileWrite {
42 path_pattern: p.to_string(),
43 }),
44 "edit" => params
45 .get("path")
46 .and_then(|v| v.as_str())
47 .map(|p| Capability::FileEdit {
48 path_pattern: p.to_string(),
49 }),
50 "ls" => params
51 .get("path")
52 .and_then(|v| v.as_str())
53 .map(|p| Capability::FileList {
54 path_pattern: p.to_string(),
55 }),
56 "find" => params
57 .get("path")
58 .and_then(|v| v.as_str())
59 .map(|p| Capability::FileFind {
60 path_pattern: p.to_string(),
61 }),
62 "bash" => {
63 let cmd = params
64 .get("command")
65 .or_else(|| params.get("cmd"))
66 .and_then(|v| v.as_str())
67 .unwrap_or("");
68 let first_word = cmd.split_whitespace().next().unwrap_or("").to_string();
69 Some(Capability::Bash {
70 allowed_commands: vec![crate::security::capability::StringPattern::Literal(
71 first_word,
72 )],
73 timeout_secs: None,
74 })
75 }
76 "browse" | "browse_extract" => {
77 let url = params.get("url").and_then(|v| v.as_str()).unwrap_or("*");
78 let domain = extract_domain(url);
79 Some(Capability::WebBrowse {
80 allowed_domains: vec![domain],
81 })
82 }
83 "web_search" => Some(Capability::Network {
84 allowed_domains: vec!["*".into()],
85 }),
86 "subagent" => Some(Capability::Subagent { max_children: None }),
87 _ => Some(Capability::ToolUse {
88 tool_name: tool_name.to_string(),
89 }),
90 }
91 }
92}
93
94fn extract_domain(url: &str) -> String {
96 url.strip_prefix("https://")
97 .or_else(|| url.strip_prefix("http://"))
98 .unwrap_or(url)
99 .split('/')
100 .next()
101 .unwrap_or("*")
102 .to_string()
103}
104
105impl Middleware for SecurityMiddleware {
106 fn name(&self) -> &str {
107 "security"
108 }
109
110 fn phases(&self) -> Vec<MiddlewarePhase> {
111 vec![MiddlewarePhase::BeforeTool]
112 }
113
114 fn handle<'a>(
115 &'a self,
116 ctx: &'a MiddlewareContext,
117 ) -> Pin<Box<dyn Future<Output = MiddlewareResult> + Send + 'a>> {
118 Box::pin(async move {
119 if let MiddlewareData::BeforeTool { tool_name, params } = &ctx.data {
120 let subject = CapabilitySubject::Agent(ctx.agent_id.clone());
121 if let Some(required) = Self::required_capability(tool_name, params)
122 && !self.authorizer.check(&subject, &required)
123 {
124 return MiddlewareResult::block(format!(
125 "Permission denied for agent {}: {:?}",
126 ctx.agent_id, required
127 ));
128 }
129 }
130 MiddlewareResult::pass()
131 })
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::observability::AuditLog;
139 use crate::security::capability::CapabilitySet;
140 use crate::security::capability::StringPattern;
141
142 fn test_mw() -> SecurityMiddleware {
143 let auth = Arc::new(Authorizer::new(Arc::new(AuditLog::new(64))));
144 SecurityMiddleware::new(auth)
145 }
146
147 fn test_mw_with_coding() -> (SecurityMiddleware, Arc<Authorizer>) {
148 let auth = Arc::new(Authorizer::new(Arc::new(AuditLog::new(64))));
149 auth.grant(
150 CapabilitySubject::Agent("a1".into()),
151 CapabilitySet::coding("/workspace"),
152 );
153 (SecurityMiddleware::new(Arc::clone(&auth)), auth)
154 }
155
156 #[test]
157 fn infer_capability_read() {
158 let cap = SecurityMiddleware::required_capability(
159 "read",
160 &serde_json::json!({"path": "/ws/file.rs"}),
161 );
162 assert!(matches!(cap, Some(Capability::FileRead { .. })));
163 }
164
165 #[test]
166 fn infer_capability_bash() {
167 let cap = SecurityMiddleware::required_capability(
168 "bash",
169 &serde_json::json!({"command": "git status"}),
170 );
171 match cap {
172 Some(Capability::Bash {
173 allowed_commands, ..
174 }) => {
175 assert!(
176 matches!(&allowed_commands[0], StringPattern::Literal(cmd) if cmd == "git")
177 );
178 }
179 _ => panic!("Expected Bash capability"),
180 }
181 }
182
183 #[test]
184 fn infer_capability_unknown_tool() {
185 let cap = SecurityMiddleware::required_capability("custom_tool", &serde_json::json!({}));
186 assert!(matches!(cap, Some(Capability::ToolUse { .. })));
187 }
188
189 #[tokio::test]
190 async fn allows_authorized_tool() {
191 let (mw, _) = test_mw_with_coding();
192 let ctx = MiddlewareContext::new(
193 MiddlewarePhase::BeforeTool,
194 "a1",
195 MiddlewareData::BeforeTool {
196 tool_name: "read".into(),
197 params: serde_json::json!({"path": "/workspace/src/main.rs"}),
198 },
199 );
200 let result = mw.handle(&ctx).await;
201 assert!(result.is_continue());
202 }
203
204 #[tokio::test]
205 async fn blocks_unauthorized_tool() {
206 let (mw, _) = test_mw_with_coding();
207 let ctx = MiddlewareContext::new(
208 MiddlewarePhase::BeforeTool,
209 "a1",
210 MiddlewareData::BeforeTool {
211 tool_name: "bash".into(),
212 params: serde_json::json!({"command": "rm -rf /"}),
213 },
214 );
215 let result = mw.handle(&ctx).await;
216 assert!(result.is_block());
217 }
218
219 #[tokio::test]
220 async fn blocks_agent_without_grants() {
221 let mw = test_mw();
222 let ctx = MiddlewareContext::new(
223 MiddlewarePhase::BeforeTool,
224 "unknown-agent",
225 MiddlewareData::BeforeTool {
226 tool_name: "read".into(),
227 params: serde_json::json!({"path": "/any/file"}),
228 },
229 );
230 let result = mw.handle(&ctx).await;
231 assert!(result.is_block());
232 }
233
234 #[test]
235 fn extract_domain_test() {
236 assert_eq!(extract_domain("https://example.com/path"), "example.com");
237 assert_eq!(extract_domain("http://sub.example.com/"), "sub.example.com");
238 assert_eq!(extract_domain("not-a-url"), "not-a-url");
239 }
240}