Skip to main content

agent_air_runtime/permissions/
target.rs

1//! Grant targets for the permission system.
2//!
3//! Targets define what a permission grant applies to:
4//! - `Path`: File system paths (files and directories)
5//! - `Domain`: Network domains for HTTP access
6//! - `Command`: Shell command patterns
7//! - `Tool`: Named tool invocations
8
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// The target of a permission grant.
13///
14/// Each target type has its own matching semantics:
15/// - `Path`: Matches file system paths with optional recursion
16/// - `Domain`: Matches network domains with wildcard support
17/// - `Command`: Matches shell commands with glob patterns
18/// - `Tool`: Matches tool names exactly
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum GrantTarget {
22    /// File or directory path target.
23    Path {
24        /// The directory or file path this grant applies to.
25        path: PathBuf,
26        /// Whether the grant applies to subdirectories recursively.
27        recursive: bool,
28    },
29    /// Network domain target for HTTP requests.
30    Domain {
31        /// Domain pattern (e.g., "api.github.com", "*.anthropic.com", "*").
32        pattern: String,
33    },
34    /// Shell command pattern target.
35    Command {
36        /// Command pattern (e.g., "git *", "cargo build", "*").
37        pattern: String,
38    },
39    /// Tool invocation target.
40    Tool {
41        /// Tool name (e.g., "switch_aws_account", "delete_resource").
42        tool_name: String,
43    },
44}
45
46impl GrantTarget {
47    /// Creates a new path target.
48    ///
49    /// # Arguments
50    /// * `path` - The directory or file path
51    /// * `recursive` - Whether to apply to subdirectories
52    pub fn path(path: impl Into<PathBuf>, recursive: bool) -> Self {
53        Self::Path {
54            path: path.into(),
55            recursive,
56        }
57    }
58
59    /// Creates a new domain target.
60    ///
61    /// # Arguments
62    /// * `pattern` - Domain pattern (e.g., "api.github.com", "*.example.com")
63    pub fn domain(pattern: impl Into<String>) -> Self {
64        Self::Domain {
65            pattern: pattern.into(),
66        }
67    }
68
69    /// Creates a new command target.
70    ///
71    /// # Arguments
72    /// * `pattern` - Command pattern (e.g., "git *", "npm install")
73    pub fn command(pattern: impl Into<String>) -> Self {
74        Self::Command {
75            pattern: pattern.into(),
76        }
77    }
78
79    /// Creates a new tool target.
80    ///
81    /// # Arguments
82    /// * `tool_name` - Tool name (e.g., "switch_aws_account", "delete_resource")
83    pub fn tool(tool_name: impl Into<String>) -> Self {
84        Self::Tool {
85            tool_name: tool_name.into(),
86        }
87    }
88
89    /// Checks if this grant target covers a request target.
90    ///
91    /// Coverage rules:
92    /// - **Path**: Grant path must be ancestor of request path (if recursive) or parent/equal (if not)
93    /// - **Domain**: Grant pattern must match request domain (wildcards supported)
94    /// - **Command**: Grant pattern must match request command (glob matching)
95    ///
96    /// # Arguments
97    /// * `request` - The target being requested
98    ///
99    /// # Returns
100    /// `true` if this grant target covers the request target
101    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            // Different target types never cover each other
132            _ => false,
133        }
134    }
135
136    /// Returns a display-friendly description of this target.
137    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    /// Returns the target type as a string for display purposes.
153    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
163/// Checks if a grant path covers a request path.
164///
165/// # Arguments
166/// * `grant_path` - The path in the grant
167/// * `request_path` - The path being requested
168/// * `recursive` - Whether the grant is recursive
169///
170/// # Returns
171/// `true` if the grant path covers the request path
172fn path_covers(grant_path: &Path, request_path: &Path, recursive: bool) -> bool {
173    // Normalize paths to handle . and .. components
174    let normalized_grant = normalize_path(grant_path);
175    let normalized_request = normalize_path(request_path);
176
177    // Check for path traversal attacks (request escaping grant)
178    if has_path_traversal(&normalized_request, &normalized_grant) {
179        return false;
180    }
181
182    if recursive {
183        // Recursive: request must be equal to or under grant path
184        normalized_request.starts_with(&normalized_grant)
185    } else {
186        // Non-recursive: request must be exact match or direct child
187        if normalized_request == normalized_grant {
188            return true;
189        }
190        // Check if request is a direct child of grant path
191        if let Some(parent) = normalized_request.parent() {
192            parent == normalized_grant
193        } else {
194            false
195        }
196    }
197}
198
199/// Normalizes a path by resolving . and .. components.
200///
201/// Note: This does NOT resolve symlinks for security reasons.
202/// Symlink resolution should be done separately before permission checks.
203fn 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                // Skip current directory markers
213            }
214            _ => {
215                normalized.push(component);
216            }
217        }
218    }
219
220    normalized
221}
222
223/// Checks if a path attempts to traverse outside a base path.
224fn has_path_traversal(request: &Path, grant: &Path) -> bool {
225    // If request doesn't start with grant after normalization,
226    // it might be attempting to escape via symlinks or other tricks
227    // This is a basic check - full symlink resolution should be done separately
228    !request.starts_with(grant) && request.parent() != Some(grant)
229}
230
231/// Checks if a domain pattern matches a domain.
232///
233/// # Pattern Rules
234/// - Exact match: "api.github.com" matches "api.github.com"
235/// - Wildcard prefix: "*.github.com" matches "api.github.com", "raw.github.com"
236/// - Full wildcard: "*" matches any domain
237fn 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        // Pattern is *.example.com, check if domain ends with .example.com
248        // or is exactly example.com
249        if domain == suffix {
250            return true;
251        }
252        if domain.ends_with(&format!(".{}", suffix)) {
253            return true;
254        }
255    }
256
257    false
258}
259
260/// Checks if a command pattern matches a command.
261///
262/// # Pattern Rules
263/// - Exact match: "git status" matches "git status"
264/// - Wildcard suffix: "git *" matches "git status", "git commit -m 'msg'"
265/// - Full wildcard: "*" matches any command
266fn 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    // Handle "cmd *" pattern - matches "cmd" followed by anything
276    if let Some(prefix) = pattern.strip_suffix(" *") {
277        // Command must start with the prefix followed by a space or be exactly the prefix
278        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            // CR-001: Verify that /project/src does NOT cover /project/src-backup
368            // This tests that path matching uses component boundaries, not string prefixes
369            let grant = GrantTarget::path("/project/src", true);
370
371            // These should NOT be covered - they share a string prefix but are different directories
372            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            // This SHOULD be covered - it's actually under /project/src
391            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            // "git" without wildcard only matches exactly "git"
488            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}