Skip to main content

oxicode_sdk/security/
middleware.rs

1//! SecurityMiddleware — tool execution authorization via the Middleware trait.
2
3use 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
13/// Middleware that checks tool execution against the Authorizer.
14///
15/// Installed in the `BeforeTool` phase. For each tool call:
16/// 1. Infers the required capability from tool name + params.
17/// 2. Checks with the Authorizer.
18/// 3. Blocks if denied.
19pub struct SecurityMiddleware {
20    authorizer: Arc<Authorizer>,
21}
22
23impl SecurityMiddleware {
24    /// Create a new security middleware with the given authorizer.
25    pub fn new(authorizer: Arc<Authorizer>) -> Self {
26        Self { authorizer }
27    }
28
29    /// Infer the capability required for a tool call from its name and params.
30    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
94/// Extract domain from a URL string.
95fn 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}