1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
4#[serde(rename_all = "lowercase")]
5pub enum Policy {
6 Allow,
7 #[default]
8 Deny,
9 Ask,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PermissionCheck {
14 Allow,
15 Deny,
16 Ask,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize, Default)]
20pub struct Permissions {
21 #[serde(default)]
22 pub policy: Policy,
23
24 #[serde(default)]
25 pub allow: Vec<String>,
26
27 #[serde(default)]
28 pub ask: Vec<String>,
29
30 #[serde(default)]
31 pub deny: Vec<String>,
32}
33
34impl Permissions {
35 pub fn none() -> Self {
36 Self {
37 policy: Policy::Deny,
38 allow: vec![],
39 ask: vec![],
40 deny: vec![],
41 }
42 }
43
44 pub fn all() -> Self {
45 Self {
46 policy: Policy::Allow,
47 allow: vec![],
48 ask: vec![],
49 deny: vec![],
50 }
51 }
52
53 pub fn ask_all() -> Self {
54 Self {
55 policy: Policy::Ask,
56 allow: vec![],
57 ask: vec![],
58 deny: vec![],
59 }
60 }
61
62 pub fn check(&self, operation: &str, resource: Option<&str>) -> PermissionCheck {
63 if self.matches_any(&self.deny, operation, resource) {
65 return PermissionCheck::Deny;
66 }
67
68 if self.matches_any(&self.ask, operation, resource) {
69 return PermissionCheck::Ask;
70 }
71
72 if self.matches_any(&self.allow, operation, resource) {
73 return PermissionCheck::Allow;
74 }
75
76 match self.policy {
77 Policy::Allow => PermissionCheck::Allow,
78 Policy::Deny => PermissionCheck::Deny,
79 Policy::Ask => PermissionCheck::Ask,
80 }
81 }
82
83 fn matches_any(&self, rules: &[String], operation: &str, resource: Option<&str>) -> bool {
84 for rule in rules {
85 if self.matches_rule(rule, operation, resource) {
86 return true;
87 }
88 }
89 false
90 }
91
92 fn matches_rule(&self, rule: &str, operation: &str, resource: Option<&str>) -> bool {
93 if let Some((rule_op, rule_pattern)) = rule.split_once(':') {
94 if !self.matches_operation(rule_op, operation) {
95 return false;
96 }
97 match resource {
98 Some(res) => self.matches_pattern(rule_pattern, res),
99 None => rule_pattern == "*",
100 }
101 } else {
102 self.matches_operation(rule, operation) && resource.is_none()
103 }
104 }
105
106 fn matches_operation(&self, rule_op: &str, operation: &str) -> bool {
107 if rule_op == "*" {
108 return true;
109 }
110 if rule_op.ends_with(".*") {
111 let prefix = &rule_op[..rule_op.len() - 1];
112 return operation.starts_with(prefix);
113 }
114 rule_op == operation
115 }
116
117 fn matches_pattern(&self, pattern: &str, value: &str) -> bool {
118 if pattern == "*" {
119 return true;
120 }
121
122 if pattern.starts_with("*.") {
123 let suffix = &pattern[1..];
124 let host = extract_host(value);
125 return host.ends_with(suffix) || host == &pattern[2..];
126 }
127
128 if pattern.contains('*') {
129 if let Ok(glob) = glob::Pattern::new(pattern) {
130 if glob.matches(value) {
131 return true;
132 }
133 }
134 let prefix = pattern.trim_end_matches('*');
135 if !prefix.is_empty() && value.starts_with(prefix) {
136 return true;
137 }
138 }
139
140 if is_url(value) {
141 let host = extract_host(value);
142 return host == pattern;
143 }
144
145 pattern == value
146 }
147
148 pub fn check_fs_read(&self, path: &str) -> PermissionCheck {
149 self.check("fs.read", Some(path))
150 }
151
152 pub fn check_fs_write(&self, path: &str) -> PermissionCheck {
153 self.check("fs.write", Some(path))
154 }
155
156 pub fn check_fs_delete(&self, path: &str) -> PermissionCheck {
157 self.check("fs.delete", Some(path))
158 }
159
160 pub fn check_http(&self, url: &str) -> PermissionCheck {
161 self.check("net.http", Some(url))
162 }
163
164 pub fn check_ws(&self, url: &str) -> PermissionCheck {
165 self.check("net.ws", Some(url))
166 }
167
168 pub fn check_process_run(&self, binary: &str) -> PermissionCheck {
169 let bin_name = std::path::Path::new(binary)
170 .file_name()
171 .and_then(|s| s.to_str())
172 .unwrap_or(binary);
173
174 let check = self.check("process.run", Some(binary));
175 if matches!(check, PermissionCheck::Allow) {
176 return check;
177 }
178 if binary != bin_name {
179 let name_check = self.check("process.run", Some(bin_name));
180 if matches!(name_check, PermissionCheck::Allow) {
181 return name_check;
182 }
183 }
184 check
185 }
186
187 pub fn check_process_shell(&self) -> PermissionCheck {
188 self.check("process.shell", None)
189 }
190
191 pub fn check_env_read(&self, var: &str) -> PermissionCheck {
192 self.check("env.read", Some(var))
193 }
194
195 pub fn check_env_write(&self) -> PermissionCheck {
196 self.check("env.write", None)
197 }
198}
199
200fn is_url(s: &str) -> bool {
201 s.starts_with("http://")
202 || s.starts_with("https://")
203 || s.starts_with("ws://")
204 || s.starts_with("wss://")
205}
206
207fn extract_host(url: &str) -> &str {
208 let without_scheme = url
209 .strip_prefix("https://")
210 .or_else(|| url.strip_prefix("http://"))
211 .or_else(|| url.strip_prefix("wss://"))
212 .or_else(|| url.strip_prefix("ws://"))
213 .unwrap_or(url);
214
215 without_scheme
216 .split('/')
217 .next()
218 .unwrap_or(without_scheme)
219 .split(':')
220 .next()
221 .unwrap_or(without_scheme)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn test_policy_default_deny() {
230 let perms = Permissions::none();
231 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
232 assert_eq!(
233 perms.check_http("https://example.com"),
234 PermissionCheck::Deny
235 );
236 assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
237 }
238
239 #[test]
240 fn test_policy_allow_all() {
241 let perms = Permissions::all();
242 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Allow);
243 assert_eq!(
244 perms.check_http("https://example.com"),
245 PermissionCheck::Allow
246 );
247 assert_eq!(perms.check_process_shell(), PermissionCheck::Allow);
248 }
249
250 #[test]
251 fn test_policy_ask_all() {
252 let perms = Permissions::ask_all();
253 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Ask);
254 assert_eq!(
255 perms.check_http("https://example.com"),
256 PermissionCheck::Ask
257 );
258 assert_eq!(perms.check_process_shell(), PermissionCheck::Ask);
259 }
260
261 #[test]
262 fn test_allow_patterns() {
263 let perms = Permissions {
264 policy: Policy::Deny,
265 allow: vec![
266 "fs.read:./data/*".to_string(),
267 "fs.read:/tmp/*".to_string(),
268 "net.http:api.github.com".to_string(),
269 "net.http:*.internal.corp".to_string(),
270 "process.run:git".to_string(),
271 "process.run:jq".to_string(),
272 "env.read:HOME".to_string(),
273 ],
274 ask: vec![],
275 deny: vec![],
276 };
277
278 assert_eq!(
279 perms.check_fs_read("./data/file.json"),
280 PermissionCheck::Allow
281 );
282 assert_eq!(perms.check_fs_read("/tmp/test"), PermissionCheck::Allow);
283 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
284
285 assert_eq!(
286 perms.check_http("https://api.github.com/repos"),
287 PermissionCheck::Allow
288 );
289 assert_eq!(
290 perms.check_http("https://foo.internal.corp/api"),
291 PermissionCheck::Allow
292 );
293 assert_eq!(perms.check_http("https://evil.com"), PermissionCheck::Deny);
294
295 assert_eq!(perms.check_process_run("git"), PermissionCheck::Allow);
296 assert_eq!(
297 perms.check_process_run("/usr/bin/git"),
298 PermissionCheck::Allow
299 );
300 assert_eq!(perms.check_process_run("rm"), PermissionCheck::Deny);
301
302 assert_eq!(perms.check_env_read("HOME"), PermissionCheck::Allow);
303 assert_eq!(perms.check_env_read("SECRET"), PermissionCheck::Deny);
304 }
305
306 #[test]
307 fn test_ask_patterns() {
308 let perms = Permissions {
309 policy: Policy::Deny,
310 allow: vec!["fs.read:./config/*".to_string()],
311 ask: vec!["fs.read:*".to_string(), "net.http:*".to_string()],
312 deny: vec!["process.shell".to_string()],
313 };
314
315 assert_eq!(
316 perms.check_fs_read("./config/settings.json"),
317 PermissionCheck::Allow
318 );
319 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Ask);
320 assert_eq!(
321 perms.check_http("https://example.com"),
322 PermissionCheck::Ask
323 );
324 assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
325 assert_eq!(perms.check_process_run("git"), PermissionCheck::Deny);
326 }
327
328 #[test]
329 fn test_priority_deny_over_ask_over_allow() {
330 let perms = Permissions {
332 policy: Policy::Allow,
333 allow: vec!["fs.read:*".to_string()],
334 ask: vec!["fs.read:/home/*".to_string()],
335 deny: vec!["fs.read:/etc/*".to_string()],
336 };
337
338 assert_eq!(perms.check_fs_read("./data/file"), PermissionCheck::Allow);
340 assert_eq!(perms.check_fs_read("/home/user/file"), PermissionCheck::Ask);
342 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
344 }
345
346 #[test]
347 fn test_ask_overrides_allow() {
348 let perms = Permissions {
349 policy: Policy::Deny,
350 allow: vec!["net.http:*".to_string()],
351 ask: vec!["net.http:*.dangerous.com".to_string()],
352 deny: vec![],
353 };
354
355 assert_eq!(perms.check_http("https://safe.com"), PermissionCheck::Allow);
356 assert_eq!(
357 perms.check_http("https://foo.dangerous.com"),
358 PermissionCheck::Ask
359 );
360 }
361
362 #[test]
363 fn test_wildcard_operation() {
364 let perms = Permissions {
365 policy: Policy::Deny,
366 allow: vec!["fs.*:./workspace/*".to_string()],
367 ask: vec![],
368 deny: vec![],
369 };
370
371 assert_eq!(
372 perms.check_fs_read("./workspace/file"),
373 PermissionCheck::Allow
374 );
375 assert_eq!(
376 perms.check_fs_write("./workspace/file"),
377 PermissionCheck::Allow
378 );
379 assert_eq!(
380 perms.check_fs_delete("./workspace/file"),
381 PermissionCheck::Allow
382 );
383 assert_eq!(perms.check_fs_read("/etc/passwd"), PermissionCheck::Deny);
384 }
385
386 #[test]
387 fn test_deserialize_permissions() {
388 let json = r#"{
389 "policy": "deny",
390 "allow": [
391 "fs.read:./data/*",
392 "net.http:api.github.com",
393 "process.run:git"
394 ],
395 "ask": [
396 "net.http:*"
397 ],
398 "deny": [
399 "process.shell"
400 ]
401 }"#;
402
403 let perms: Permissions = serde_json::from_str(json).unwrap();
404
405 assert_eq!(perms.policy, Policy::Deny);
406 assert_eq!(perms.check_fs_read("./data/test"), PermissionCheck::Allow);
407 assert_eq!(
408 perms.check_http("https://api.github.com"),
409 PermissionCheck::Allow
410 );
411 assert_eq!(perms.check_http("https://other.com"), PermissionCheck::Ask);
412 assert_eq!(perms.check_process_shell(), PermissionCheck::Deny);
413 }
414
415 #[test]
416 fn test_extract_host() {
417 assert_eq!(
418 extract_host("https://api.example.com/v1"),
419 "api.example.com"
420 );
421 assert_eq!(extract_host("http://localhost:8080/path"), "localhost");
422 assert_eq!(
423 extract_host("wss://stream.example.com"),
424 "stream.example.com"
425 );
426 }
427}