agent_air_runtime/permissions/
target.rs1use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum GrantTarget {
22 Path {
24 path: PathBuf,
26 recursive: bool,
28 },
29 Domain {
31 pattern: String,
33 },
34 Command {
36 pattern: String,
38 },
39 Tool {
41 tool_name: String,
43 },
44}
45
46impl GrantTarget {
47 pub fn path(path: impl Into<PathBuf>, recursive: bool) -> Self {
53 Self::Path {
54 path: path.into(),
55 recursive,
56 }
57 }
58
59 pub fn domain(pattern: impl Into<String>) -> Self {
64 Self::Domain {
65 pattern: pattern.into(),
66 }
67 }
68
69 pub fn command(pattern: impl Into<String>) -> Self {
74 Self::Command {
75 pattern: pattern.into(),
76 }
77 }
78
79 pub fn tool(tool_name: impl Into<String>) -> Self {
84 Self::Tool {
85 tool_name: tool_name.into(),
86 }
87 }
88
89 pub fn covers(&self, request: &GrantTarget) -> bool {
102 match (self, request) {
103 (
104 GrantTarget::Path {
105 path: grant_path,
106 recursive,
107 },
108 GrantTarget::Path {
109 path: request_path, ..
110 },
111 ) => path_covers(grant_path, request_path, *recursive),
112
113 (GrantTarget::Domain { pattern: grant }, GrantTarget::Domain { pattern: request }) => {
114 domain_pattern_matches(grant, request)
115 }
116
117 (
118 GrantTarget::Command { pattern: grant },
119 GrantTarget::Command { pattern: request },
120 ) => command_pattern_matches(grant, request),
121
122 (
123 GrantTarget::Tool {
124 tool_name: grant_name,
125 },
126 GrantTarget::Tool {
127 tool_name: request_name,
128 },
129 ) => grant_name == request_name,
130
131 _ => false,
133 }
134 }
135
136 pub fn description(&self) -> String {
138 match self {
139 GrantTarget::Path { path, recursive } => {
140 if *recursive {
141 format!("{} (recursive)", path.display())
142 } else {
143 format!("{}", path.display())
144 }
145 }
146 GrantTarget::Domain { pattern } => pattern.clone(),
147 GrantTarget::Command { pattern } => pattern.clone(),
148 GrantTarget::Tool { tool_name } => tool_name.clone(),
149 }
150 }
151
152 pub fn target_type(&self) -> &'static str {
154 match self {
155 GrantTarget::Path { .. } => "Path",
156 GrantTarget::Domain { .. } => "Domain",
157 GrantTarget::Command { .. } => "Command",
158 GrantTarget::Tool { .. } => "Tool",
159 }
160 }
161}
162
163fn path_covers(grant_path: &Path, request_path: &Path, recursive: bool) -> bool {
173 let normalized_grant = normalize_path(grant_path);
175 let normalized_request = normalize_path(request_path);
176
177 if has_path_traversal(&normalized_request, &normalized_grant) {
179 return false;
180 }
181
182 if recursive {
183 normalized_request.starts_with(&normalized_grant)
185 } else {
186 if normalized_request == normalized_grant {
188 return true;
189 }
190 if let Some(parent) = normalized_request.parent() {
192 parent == normalized_grant
193 } else {
194 false
195 }
196 }
197}
198
199fn normalize_path(path: &Path) -> PathBuf {
204 let mut normalized = PathBuf::new();
205
206 for component in path.components() {
207 match component {
208 std::path::Component::ParentDir => {
209 normalized.pop();
210 }
211 std::path::Component::CurDir => {
212 }
214 _ => {
215 normalized.push(component);
216 }
217 }
218 }
219
220 normalized
221}
222
223fn has_path_traversal(request: &Path, grant: &Path) -> bool {
225 !request.starts_with(grant) && request.parent() != Some(grant)
229}
230
231fn domain_pattern_matches(pattern: &str, domain: &str) -> bool {
238 if pattern == "*" {
239 return true;
240 }
241
242 if pattern == domain {
243 return true;
244 }
245
246 if let Some(suffix) = pattern.strip_prefix("*.") {
247 if domain == suffix {
250 return true;
251 }
252 if domain.ends_with(&format!(".{}", suffix)) {
253 return true;
254 }
255 }
256
257 false
258}
259
260fn command_pattern_matches(pattern: &str, command: &str) -> bool {
267 if pattern == "*" {
268 return true;
269 }
270
271 if pattern == command {
272 return true;
273 }
274
275 if let Some(prefix) = pattern.strip_suffix(" *") {
277 if command == prefix {
279 return true;
280 }
281 if command.starts_with(&format!("{} ", prefix)) {
282 return true;
283 }
284 }
285
286 false
287}
288
289impl std::fmt::Display for GrantTarget {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 write!(f, "{}", self.description())
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 mod path_tests {
300 use super::*;
301
302 #[test]
303 fn test_exact_path_match() {
304 let grant = GrantTarget::path("/project/src", false);
305 let request = GrantTarget::path("/project/src", false);
306 assert!(grant.covers(&request));
307 }
308
309 #[test]
310 fn test_direct_child_non_recursive() {
311 let grant = GrantTarget::path("/project/src", false);
312 let request = GrantTarget::path("/project/src/main.rs", false);
313 assert!(grant.covers(&request));
314 }
315
316 #[test]
317 fn test_nested_child_non_recursive_fails() {
318 let grant = GrantTarget::path("/project/src", false);
319 let request = GrantTarget::path("/project/src/utils/mod.rs", false);
320 assert!(!grant.covers(&request));
321 }
322
323 #[test]
324 fn test_recursive_covers_nested() {
325 let grant = GrantTarget::path("/project/src", true);
326 let request = GrantTarget::path("/project/src/utils/mod.rs", false);
327 assert!(grant.covers(&request));
328 }
329
330 #[test]
331 fn test_recursive_covers_deep_nested() {
332 let grant = GrantTarget::path("/project", true);
333 let request = GrantTarget::path("/project/src/utils/helpers/mod.rs", false);
334 assert!(grant.covers(&request));
335 }
336
337 #[test]
338 fn test_sibling_path_not_covered() {
339 let grant = GrantTarget::path("/project/src", true);
340 let request = GrantTarget::path("/project/tests/test.rs", false);
341 assert!(!grant.covers(&request));
342 }
343
344 #[test]
345 fn test_parent_path_not_covered() {
346 let grant = GrantTarget::path("/project/src", true);
347 let request = GrantTarget::path("/project/Cargo.toml", false);
348 assert!(!grant.covers(&request));
349 }
350
351 #[test]
352 fn test_path_traversal_blocked() {
353 let grant = GrantTarget::path("/project/src", true);
354 let request = GrantTarget::path("/project/src/../secrets/key.pem", false);
355 assert!(!grant.covers(&request));
356 }
357
358 #[test]
359 fn test_unrelated_path_not_covered() {
360 let grant = GrantTarget::path("/project", true);
361 let request = GrantTarget::path("/etc/passwd", false);
362 assert!(!grant.covers(&request));
363 }
364
365 #[test]
366 fn test_path_prefix_collision_not_covered() {
367 let grant = GrantTarget::path("/project/src", true);
370
371 let request1 = GrantTarget::path("/project/src-backup/file.rs", false);
373 assert!(
374 !grant.covers(&request1),
375 "/project/src should not cover /project/src-backup"
376 );
377
378 let request2 = GrantTarget::path("/project/srcrc/file.rs", false);
379 assert!(
380 !grant.covers(&request2),
381 "/project/src should not cover /project/srcrc"
382 );
383
384 let request3 = GrantTarget::path("/project/src_old/file.rs", false);
385 assert!(
386 !grant.covers(&request3),
387 "/project/src should not cover /project/src_old"
388 );
389
390 let request4 = GrantTarget::path("/project/src/backup/file.rs", false);
392 assert!(
393 grant.covers(&request4),
394 "/project/src should cover /project/src/backup"
395 );
396 }
397 }
398
399 mod domain_tests {
400 use super::*;
401
402 #[test]
403 fn test_exact_domain_match() {
404 let grant = GrantTarget::domain("api.github.com");
405 let request = GrantTarget::domain("api.github.com");
406 assert!(grant.covers(&request));
407 }
408
409 #[test]
410 fn test_wildcard_subdomain() {
411 let grant = GrantTarget::domain("*.github.com");
412 let request = GrantTarget::domain("api.github.com");
413 assert!(grant.covers(&request));
414 }
415
416 #[test]
417 fn test_wildcard_matches_base_domain() {
418 let grant = GrantTarget::domain("*.github.com");
419 let request = GrantTarget::domain("github.com");
420 assert!(grant.covers(&request));
421 }
422
423 #[test]
424 fn test_wildcard_all() {
425 let grant = GrantTarget::domain("*");
426 let request = GrantTarget::domain("any.domain.com");
427 assert!(grant.covers(&request));
428 }
429
430 #[test]
431 fn test_different_domain_not_covered() {
432 let grant = GrantTarget::domain("api.github.com");
433 let request = GrantTarget::domain("api.gitlab.com");
434 assert!(!grant.covers(&request));
435 }
436
437 #[test]
438 fn test_wildcard_only_matches_direct_subdomains() {
439 let grant = GrantTarget::domain("*.github.com");
440 let request = GrantTarget::domain("evil.com");
441 assert!(!grant.covers(&request));
442 }
443 }
444
445 mod command_tests {
446 use super::*;
447
448 #[test]
449 fn test_exact_command_match() {
450 let grant = GrantTarget::command("git status");
451 let request = GrantTarget::command("git status");
452 assert!(grant.covers(&request));
453 }
454
455 #[test]
456 fn test_wildcard_command() {
457 let grant = GrantTarget::command("git *");
458 let request = GrantTarget::command("git status");
459 assert!(grant.covers(&request));
460 }
461
462 #[test]
463 fn test_wildcard_command_with_args() {
464 let grant = GrantTarget::command("git *");
465 let request = GrantTarget::command("git commit -m 'message'");
466 assert!(grant.covers(&request));
467 }
468
469 #[test]
470 fn test_wildcard_all_commands() {
471 let grant = GrantTarget::command("*");
472 let request = GrantTarget::command("rm -rf /");
473 assert!(grant.covers(&request));
474 }
475
476 #[test]
477 fn test_different_command_not_covered() {
478 let grant = GrantTarget::command("git *");
479 let request = GrantTarget::command("docker run nginx");
480 assert!(!grant.covers(&request));
481 }
482
483 #[test]
484 fn test_partial_command_not_covered() {
485 let grant = GrantTarget::command("git");
486 let request = GrantTarget::command("git status");
487 assert!(!grant.covers(&request));
489 }
490
491 #[test]
492 fn test_wildcard_command_matches_bare_command() {
493 let grant = GrantTarget::command("git *");
494 let request = GrantTarget::command("git");
495 assert!(grant.covers(&request));
496 }
497 }
498
499 mod tool_tests {
500 use super::*;
501
502 #[test]
503 fn test_exact_tool_match() {
504 let grant = GrantTarget::tool("switch_aws_account");
505 let request = GrantTarget::tool("switch_aws_account");
506 assert!(grant.covers(&request));
507 }
508
509 #[test]
510 fn test_different_tool_not_covered() {
511 let grant = GrantTarget::tool("switch_aws_account");
512 let request = GrantTarget::tool("delete_resource");
513 assert!(!grant.covers(&request));
514 }
515
516 #[test]
517 fn test_tool_description() {
518 let target = GrantTarget::tool("switch_aws_account");
519 assert_eq!(target.description(), "switch_aws_account");
520 }
521
522 #[test]
523 fn test_tool_target_type() {
524 let target = GrantTarget::tool("my_tool");
525 assert_eq!(target.target_type(), "Tool");
526 }
527 }
528
529 mod cross_target_tests {
530 use super::*;
531
532 #[test]
533 fn test_different_target_types_dont_match() {
534 let path_grant = GrantTarget::path("/project", true);
535 let domain_request = GrantTarget::domain("github.com");
536 assert!(!path_grant.covers(&domain_request));
537
538 let command_grant = GrantTarget::command("git *");
539 let path_request = GrantTarget::path("/project/src", false);
540 assert!(!command_grant.covers(&path_request));
541
542 let tool_grant = GrantTarget::tool("my_tool");
543 let command_request = GrantTarget::command("my_tool");
544 assert!(!tool_grant.covers(&command_request));
545 }
546 }
547
548 mod serialization_tests {
549 use super::*;
550
551 #[test]
552 fn test_path_serialization() {
553 let target = GrantTarget::path("/project/src", true);
554 let json = serde_json::to_string(&target).unwrap();
555 assert!(json.contains("\"type\":\"path\""));
556 assert!(json.contains("\"recursive\":true"));
557
558 let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
559 assert_eq!(deserialized, target);
560 }
561
562 #[test]
563 fn test_domain_serialization() {
564 let target = GrantTarget::domain("*.github.com");
565 let json = serde_json::to_string(&target).unwrap();
566 assert!(json.contains("\"type\":\"domain\""));
567
568 let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
569 assert_eq!(deserialized, target);
570 }
571
572 #[test]
573 fn test_command_serialization() {
574 let target = GrantTarget::command("git *");
575 let json = serde_json::to_string(&target).unwrap();
576 assert!(json.contains("\"type\":\"command\""));
577
578 let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
579 assert_eq!(deserialized, target);
580 }
581
582 #[test]
583 fn test_tool_serialization() {
584 let target = GrantTarget::tool("switch_aws_account");
585 let json = serde_json::to_string(&target).unwrap();
586 assert!(json.contains("\"type\":\"tool\""));
587 assert!(json.contains("\"tool_name\":\"switch_aws_account\""));
588
589 let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
590 assert_eq!(deserialized, target);
591 }
592 }
593}