Skip to main content

agent_core_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
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11/// The target of a permission grant.
12///
13/// Each target type has its own matching semantics:
14/// - `Path`: Matches file system paths with optional recursion
15/// - `Domain`: Matches network domains with wildcard support
16/// - `Command`: Matches shell commands with glob patterns
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum GrantTarget {
20    /// File or directory path target.
21    Path {
22        /// The directory or file path this grant applies to.
23        path: PathBuf,
24        /// Whether the grant applies to subdirectories recursively.
25        recursive: bool,
26    },
27    /// Network domain target for HTTP requests.
28    Domain {
29        /// Domain pattern (e.g., "api.github.com", "*.anthropic.com", "*").
30        pattern: String,
31    },
32    /// Shell command pattern target.
33    Command {
34        /// Command pattern (e.g., "git *", "cargo build", "*").
35        pattern: String,
36    },
37}
38
39impl GrantTarget {
40    /// Creates a new path target.
41    ///
42    /// # Arguments
43    /// * `path` - The directory or file path
44    /// * `recursive` - Whether to apply to subdirectories
45    pub fn path(path: impl Into<PathBuf>, recursive: bool) -> Self {
46        Self::Path {
47            path: path.into(),
48            recursive,
49        }
50    }
51
52    /// Creates a new domain target.
53    ///
54    /// # Arguments
55    /// * `pattern` - Domain pattern (e.g., "api.github.com", "*.example.com")
56    pub fn domain(pattern: impl Into<String>) -> Self {
57        Self::Domain {
58            pattern: pattern.into(),
59        }
60    }
61
62    /// Creates a new command target.
63    ///
64    /// # Arguments
65    /// * `pattern` - Command pattern (e.g., "git *", "npm install")
66    pub fn command(pattern: impl Into<String>) -> Self {
67        Self::Command {
68            pattern: pattern.into(),
69        }
70    }
71
72    /// Checks if this grant target covers a request target.
73    ///
74    /// Coverage rules:
75    /// - **Path**: Grant path must be ancestor of request path (if recursive) or parent/equal (if not)
76    /// - **Domain**: Grant pattern must match request domain (wildcards supported)
77    /// - **Command**: Grant pattern must match request command (glob matching)
78    ///
79    /// # Arguments
80    /// * `request` - The target being requested
81    ///
82    /// # Returns
83    /// `true` if this grant target covers the request target
84    pub fn covers(&self, request: &GrantTarget) -> bool {
85        match (self, request) {
86            (
87                GrantTarget::Path {
88                    path: grant_path,
89                    recursive,
90                },
91                GrantTarget::Path {
92                    path: request_path, ..
93                },
94            ) => path_covers(grant_path, request_path, *recursive),
95
96            (
97                GrantTarget::Domain { pattern: grant },
98                GrantTarget::Domain { pattern: request },
99            ) => domain_pattern_matches(grant, request),
100
101            (
102                GrantTarget::Command { pattern: grant },
103                GrantTarget::Command { pattern: request },
104            ) => command_pattern_matches(grant, request),
105
106            // Different target types never cover each other
107            _ => false,
108        }
109    }
110
111    /// Returns a display-friendly description of this target.
112    pub fn description(&self) -> String {
113        match self {
114            GrantTarget::Path { path, recursive } => {
115                if *recursive {
116                    format!("{} (recursive)", path.display())
117                } else {
118                    format!("{}", path.display())
119                }
120            }
121            GrantTarget::Domain { pattern } => pattern.clone(),
122            GrantTarget::Command { pattern } => pattern.clone(),
123        }
124    }
125
126    /// Returns the target type as a string for display purposes.
127    pub fn target_type(&self) -> &'static str {
128        match self {
129            GrantTarget::Path { .. } => "Path",
130            GrantTarget::Domain { .. } => "Domain",
131            GrantTarget::Command { .. } => "Command",
132        }
133    }
134}
135
136/// Checks if a grant path covers a request path.
137///
138/// # Arguments
139/// * `grant_path` - The path in the grant
140/// * `request_path` - The path being requested
141/// * `recursive` - Whether the grant is recursive
142///
143/// # Returns
144/// `true` if the grant path covers the request path
145fn path_covers(grant_path: &Path, request_path: &Path, recursive: bool) -> bool {
146    // Normalize paths to handle . and .. components
147    let normalized_grant = normalize_path(grant_path);
148    let normalized_request = normalize_path(request_path);
149
150    // Check for path traversal attacks (request escaping grant)
151    if has_path_traversal(&normalized_request, &normalized_grant) {
152        return false;
153    }
154
155    if recursive {
156        // Recursive: request must be equal to or under grant path
157        normalized_request.starts_with(&normalized_grant)
158    } else {
159        // Non-recursive: request must be exact match or direct child
160        if normalized_request == normalized_grant {
161            return true;
162        }
163        // Check if request is a direct child of grant path
164        if let Some(parent) = normalized_request.parent() {
165            parent == normalized_grant
166        } else {
167            false
168        }
169    }
170}
171
172/// Normalizes a path by resolving . and .. components.
173///
174/// Note: This does NOT resolve symlinks for security reasons.
175/// Symlink resolution should be done separately before permission checks.
176fn normalize_path(path: &Path) -> PathBuf {
177    let mut normalized = PathBuf::new();
178
179    for component in path.components() {
180        match component {
181            std::path::Component::ParentDir => {
182                normalized.pop();
183            }
184            std::path::Component::CurDir => {
185                // Skip current directory markers
186            }
187            _ => {
188                normalized.push(component);
189            }
190        }
191    }
192
193    normalized
194}
195
196/// Checks if a path attempts to traverse outside a base path.
197fn has_path_traversal(request: &Path, grant: &Path) -> bool {
198    // If request doesn't start with grant after normalization,
199    // it might be attempting to escape via symlinks or other tricks
200    // This is a basic check - full symlink resolution should be done separately
201    !request.starts_with(grant) && !request.parent().map_or(false, |p| p == grant)
202}
203
204/// Checks if a domain pattern matches a domain.
205///
206/// # Pattern Rules
207/// - Exact match: "api.github.com" matches "api.github.com"
208/// - Wildcard prefix: "*.github.com" matches "api.github.com", "raw.github.com"
209/// - Full wildcard: "*" matches any domain
210fn domain_pattern_matches(pattern: &str, domain: &str) -> bool {
211    if pattern == "*" {
212        return true;
213    }
214
215    if pattern == domain {
216        return true;
217    }
218
219    if let Some(suffix) = pattern.strip_prefix("*.") {
220        // Pattern is *.example.com, check if domain ends with .example.com
221        // or is exactly example.com
222        if domain == suffix {
223            return true;
224        }
225        if domain.ends_with(&format!(".{}", suffix)) {
226            return true;
227        }
228    }
229
230    false
231}
232
233/// Checks if a command pattern matches a command.
234///
235/// # Pattern Rules
236/// - Exact match: "git status" matches "git status"
237/// - Wildcard suffix: "git *" matches "git status", "git commit -m 'msg'"
238/// - Full wildcard: "*" matches any command
239fn command_pattern_matches(pattern: &str, command: &str) -> bool {
240    if pattern == "*" {
241        return true;
242    }
243
244    if pattern == command {
245        return true;
246    }
247
248    // Handle "cmd *" pattern - matches "cmd" followed by anything
249    if let Some(prefix) = pattern.strip_suffix(" *") {
250        // Command must start with the prefix followed by a space or be exactly the prefix
251        if command == prefix {
252            return true;
253        }
254        if command.starts_with(&format!("{} ", prefix)) {
255            return true;
256        }
257    }
258
259    false
260}
261
262impl std::fmt::Display for GrantTarget {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        write!(f, "{}", self.description())
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    mod path_tests {
273        use super::*;
274
275        #[test]
276        fn test_exact_path_match() {
277            let grant = GrantTarget::path("/project/src", false);
278            let request = GrantTarget::path("/project/src", false);
279            assert!(grant.covers(&request));
280        }
281
282        #[test]
283        fn test_direct_child_non_recursive() {
284            let grant = GrantTarget::path("/project/src", false);
285            let request = GrantTarget::path("/project/src/main.rs", false);
286            assert!(grant.covers(&request));
287        }
288
289        #[test]
290        fn test_nested_child_non_recursive_fails() {
291            let grant = GrantTarget::path("/project/src", false);
292            let request = GrantTarget::path("/project/src/utils/mod.rs", false);
293            assert!(!grant.covers(&request));
294        }
295
296        #[test]
297        fn test_recursive_covers_nested() {
298            let grant = GrantTarget::path("/project/src", true);
299            let request = GrantTarget::path("/project/src/utils/mod.rs", false);
300            assert!(grant.covers(&request));
301        }
302
303        #[test]
304        fn test_recursive_covers_deep_nested() {
305            let grant = GrantTarget::path("/project", true);
306            let request = GrantTarget::path("/project/src/utils/helpers/mod.rs", false);
307            assert!(grant.covers(&request));
308        }
309
310        #[test]
311        fn test_sibling_path_not_covered() {
312            let grant = GrantTarget::path("/project/src", true);
313            let request = GrantTarget::path("/project/tests/test.rs", false);
314            assert!(!grant.covers(&request));
315        }
316
317        #[test]
318        fn test_parent_path_not_covered() {
319            let grant = GrantTarget::path("/project/src", true);
320            let request = GrantTarget::path("/project/Cargo.toml", false);
321            assert!(!grant.covers(&request));
322        }
323
324        #[test]
325        fn test_path_traversal_blocked() {
326            let grant = GrantTarget::path("/project/src", true);
327            let request = GrantTarget::path("/project/src/../secrets/key.pem", false);
328            assert!(!grant.covers(&request));
329        }
330
331        #[test]
332        fn test_unrelated_path_not_covered() {
333            let grant = GrantTarget::path("/project", true);
334            let request = GrantTarget::path("/etc/passwd", false);
335            assert!(!grant.covers(&request));
336        }
337
338        #[test]
339        fn test_path_prefix_collision_not_covered() {
340            // CR-001: Verify that /project/src does NOT cover /project/src-backup
341            // This tests that path matching uses component boundaries, not string prefixes
342            let grant = GrantTarget::path("/project/src", true);
343
344            // These should NOT be covered - they share a string prefix but are different directories
345            let request1 = GrantTarget::path("/project/src-backup/file.rs", false);
346            assert!(!grant.covers(&request1), "/project/src should not cover /project/src-backup");
347
348            let request2 = GrantTarget::path("/project/srcrc/file.rs", false);
349            assert!(!grant.covers(&request2), "/project/src should not cover /project/srcrc");
350
351            let request3 = GrantTarget::path("/project/src_old/file.rs", false);
352            assert!(!grant.covers(&request3), "/project/src should not cover /project/src_old");
353
354            // This SHOULD be covered - it's actually under /project/src
355            let request4 = GrantTarget::path("/project/src/backup/file.rs", false);
356            assert!(grant.covers(&request4), "/project/src should cover /project/src/backup");
357        }
358    }
359
360    mod domain_tests {
361        use super::*;
362
363        #[test]
364        fn test_exact_domain_match() {
365            let grant = GrantTarget::domain("api.github.com");
366            let request = GrantTarget::domain("api.github.com");
367            assert!(grant.covers(&request));
368        }
369
370        #[test]
371        fn test_wildcard_subdomain() {
372            let grant = GrantTarget::domain("*.github.com");
373            let request = GrantTarget::domain("api.github.com");
374            assert!(grant.covers(&request));
375        }
376
377        #[test]
378        fn test_wildcard_matches_base_domain() {
379            let grant = GrantTarget::domain("*.github.com");
380            let request = GrantTarget::domain("github.com");
381            assert!(grant.covers(&request));
382        }
383
384        #[test]
385        fn test_wildcard_all() {
386            let grant = GrantTarget::domain("*");
387            let request = GrantTarget::domain("any.domain.com");
388            assert!(grant.covers(&request));
389        }
390
391        #[test]
392        fn test_different_domain_not_covered() {
393            let grant = GrantTarget::domain("api.github.com");
394            let request = GrantTarget::domain("api.gitlab.com");
395            assert!(!grant.covers(&request));
396        }
397
398        #[test]
399        fn test_wildcard_only_matches_direct_subdomains() {
400            let grant = GrantTarget::domain("*.github.com");
401            let request = GrantTarget::domain("evil.com");
402            assert!(!grant.covers(&request));
403        }
404    }
405
406    mod command_tests {
407        use super::*;
408
409        #[test]
410        fn test_exact_command_match() {
411            let grant = GrantTarget::command("git status");
412            let request = GrantTarget::command("git status");
413            assert!(grant.covers(&request));
414        }
415
416        #[test]
417        fn test_wildcard_command() {
418            let grant = GrantTarget::command("git *");
419            let request = GrantTarget::command("git status");
420            assert!(grant.covers(&request));
421        }
422
423        #[test]
424        fn test_wildcard_command_with_args() {
425            let grant = GrantTarget::command("git *");
426            let request = GrantTarget::command("git commit -m 'message'");
427            assert!(grant.covers(&request));
428        }
429
430        #[test]
431        fn test_wildcard_all_commands() {
432            let grant = GrantTarget::command("*");
433            let request = GrantTarget::command("rm -rf /");
434            assert!(grant.covers(&request));
435        }
436
437        #[test]
438        fn test_different_command_not_covered() {
439            let grant = GrantTarget::command("git *");
440            let request = GrantTarget::command("docker run nginx");
441            assert!(!grant.covers(&request));
442        }
443
444        #[test]
445        fn test_partial_command_not_covered() {
446            let grant = GrantTarget::command("git");
447            let request = GrantTarget::command("git status");
448            // "git" without wildcard only matches exactly "git"
449            assert!(!grant.covers(&request));
450        }
451
452        #[test]
453        fn test_wildcard_command_matches_bare_command() {
454            let grant = GrantTarget::command("git *");
455            let request = GrantTarget::command("git");
456            assert!(grant.covers(&request));
457        }
458    }
459
460    mod cross_target_tests {
461        use super::*;
462
463        #[test]
464        fn test_different_target_types_dont_match() {
465            let path_grant = GrantTarget::path("/project", true);
466            let domain_request = GrantTarget::domain("github.com");
467            assert!(!path_grant.covers(&domain_request));
468
469            let command_grant = GrantTarget::command("git *");
470            let path_request = GrantTarget::path("/project/src", false);
471            assert!(!command_grant.covers(&path_request));
472        }
473    }
474
475    mod serialization_tests {
476        use super::*;
477
478        #[test]
479        fn test_path_serialization() {
480            let target = GrantTarget::path("/project/src", true);
481            let json = serde_json::to_string(&target).unwrap();
482            assert!(json.contains("\"type\":\"path\""));
483            assert!(json.contains("\"recursive\":true"));
484
485            let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
486            assert_eq!(deserialized, target);
487        }
488
489        #[test]
490        fn test_domain_serialization() {
491            let target = GrantTarget::domain("*.github.com");
492            let json = serde_json::to_string(&target).unwrap();
493            assert!(json.contains("\"type\":\"domain\""));
494
495            let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
496            assert_eq!(deserialized, target);
497        }
498
499        #[test]
500        fn test_command_serialization() {
501            let target = GrantTarget::command("git *");
502            let json = serde_json::to_string(&target).unwrap();
503            assert!(json.contains("\"type\":\"command\""));
504
505            let deserialized: GrantTarget = serde_json::from_str(&json).unwrap();
506            assert_eq!(deserialized, target);
507        }
508    }
509}