agent_core_runtime/permissions/
target.rs1use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(tag = "type", rename_all = "snake_case")]
19pub enum GrantTarget {
20 Path {
22 path: PathBuf,
24 recursive: bool,
26 },
27 Domain {
29 pattern: String,
31 },
32 Command {
34 pattern: String,
36 },
37}
38
39impl GrantTarget {
40 pub fn path(path: impl Into<PathBuf>, recursive: bool) -> Self {
46 Self::Path {
47 path: path.into(),
48 recursive,
49 }
50 }
51
52 pub fn domain(pattern: impl Into<String>) -> Self {
57 Self::Domain {
58 pattern: pattern.into(),
59 }
60 }
61
62 pub fn command(pattern: impl Into<String>) -> Self {
67 Self::Command {
68 pattern: pattern.into(),
69 }
70 }
71
72 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 _ => false,
108 }
109 }
110
111 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 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
136fn path_covers(grant_path: &Path, request_path: &Path, recursive: bool) -> bool {
146 let normalized_grant = normalize_path(grant_path);
148 let normalized_request = normalize_path(request_path);
149
150 if has_path_traversal(&normalized_request, &normalized_grant) {
152 return false;
153 }
154
155 if recursive {
156 normalized_request.starts_with(&normalized_grant)
158 } else {
159 if normalized_request == normalized_grant {
161 return true;
162 }
163 if let Some(parent) = normalized_request.parent() {
165 parent == normalized_grant
166 } else {
167 false
168 }
169 }
170}
171
172fn 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 }
187 _ => {
188 normalized.push(component);
189 }
190 }
191 }
192
193 normalized
194}
195
196fn has_path_traversal(request: &Path, grant: &Path) -> bool {
198 !request.starts_with(grant) && !request.parent().map_or(false, |p| p == grant)
202}
203
204fn 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 if domain == suffix {
223 return true;
224 }
225 if domain.ends_with(&format!(".{}", suffix)) {
226 return true;
227 }
228 }
229
230 false
231}
232
233fn 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 if let Some(prefix) = pattern.strip_suffix(" *") {
250 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 let grant = GrantTarget::path("/project/src", true);
343
344 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 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 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}