1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct AgentCapabilities {
26 pub read: bool,
28 pub write: bool,
30 pub exec: bool,
32 pub allowed_paths: Vec<String>,
34 pub denied_paths: Vec<String>,
36 pub allowed_commands: Vec<String>,
38 pub denied_commands: Vec<String>,
40}
41
42impl Default for AgentCapabilities {
43 fn default() -> Self {
44 Self::full_access()
45 }
46}
47
48impl AgentCapabilities {
49 #[must_use]
51 pub const fn none() -> Self {
52 Self {
53 read: false,
54 write: false,
55 exec: false,
56 allowed_paths: vec![],
57 denied_paths: vec![],
58 allowed_commands: vec![],
59 denied_commands: vec![],
60 }
61 }
62
63 #[must_use]
65 pub const fn read_only() -> Self {
66 Self {
67 read: true,
68 write: false,
69 exec: false,
70 allowed_paths: vec![],
71 denied_paths: vec![],
72 allowed_commands: vec![],
73 denied_commands: vec![],
74 }
75 }
76
77 #[must_use]
79 pub const fn full_access() -> Self {
80 Self {
81 read: true,
82 write: true,
83 exec: true,
84 allowed_paths: vec![],
85 denied_paths: vec![],
86 allowed_commands: vec![],
87 denied_commands: vec![],
88 }
89 }
90
91 #[must_use]
93 pub const fn with_read(mut self, enabled: bool) -> Self {
94 self.read = enabled;
95 self
96 }
97
98 #[must_use]
100 pub const fn with_write(mut self, enabled: bool) -> Self {
101 self.write = enabled;
102 self
103 }
104
105 #[must_use]
107 pub const fn with_exec(mut self, enabled: bool) -> Self {
108 self.exec = enabled;
109 self
110 }
111
112 #[must_use]
114 pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
115 self.allowed_paths = paths;
116 self
117 }
118
119 #[must_use]
121 pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
122 self.denied_paths = paths;
123 self
124 }
125
126 #[must_use]
128 pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
129 self.allowed_commands = commands;
130 self
131 }
132
133 #[must_use]
135 pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
136 self.denied_commands = commands;
137 self
138 }
139
140 pub fn check_read(&self, path: &str) -> Result<(), String> {
147 if !self.read {
148 return Err("read access is disabled".into());
149 }
150 self.check_path(path)
151 }
152
153 pub fn check_write(&self, path: &str) -> Result<(), String> {
160 if !self.write {
161 return Err("write access is disabled".into());
162 }
163 self.check_path(path)
164 }
165
166 pub fn check_exec(&self, command: &str) -> Result<(), String> {
173 if !self.exec {
174 return Err("command execution is disabled".into());
175 }
176 self.check_command(command)
177 }
178
179 #[must_use]
181 pub fn can_read(&self, path: &str) -> bool {
182 self.check_read(path).is_ok()
183 }
184
185 #[must_use]
187 pub fn can_write(&self, path: &str) -> bool {
188 self.check_write(path).is_ok()
189 }
190
191 #[must_use]
193 pub fn can_exec(&self, command: &str) -> bool {
194 self.check_exec(command).is_ok()
195 }
196
197 pub fn check_path(&self, path: &str) -> Result<(), String> {
205 for pattern in &self.denied_paths {
207 if glob_match(pattern, path) {
208 return Err(format!("path matches denied pattern '{pattern}'"));
209 }
210 }
211
212 if self.allowed_paths.is_empty() {
214 return Ok(());
215 }
216
217 for pattern in &self.allowed_paths {
219 if glob_match(pattern, path) {
220 return Ok(());
221 }
222 }
223
224 Err(format!(
225 "path not in allowed list (allowed: [{}])",
226 self.allowed_paths.join(", ")
227 ))
228 }
229
230 pub fn check_command(&self, command: &str) -> Result<(), String> {
249 for pattern in &self.denied_commands {
251 if regex_match_deny(pattern, command) {
252 return Err(format!("command matches denied pattern '{pattern}'"));
253 }
254 }
255
256 if self.allowed_commands.is_empty() {
258 return Ok(());
259 }
260
261 for pattern in &self.allowed_commands {
263 if regex_match(pattern, command) {
264 return Ok(());
265 }
266 }
267
268 Err(format!(
269 "command not in allowed list (allowed: [{}])",
270 self.allowed_commands.join(", ")
271 ))
272 }
273}
274
275fn glob_match(pattern: &str, path: &str) -> bool {
277 if pattern == "**" {
279 return true; }
281
282 let mut escaped = String::new();
284 for c in pattern.chars() {
285 match c {
286 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
287 escaped.push('\\');
288 escaped.push(c);
289 }
290 _ => escaped.push(c),
291 }
292 }
293
294 let pattern = escaped
299 .replace("**/", "\x00") .replace("/**", "\x01") .replace('*', "[^/]*") .replace('\x00', "(.*/)?") .replace('\x01', "(/.*)?"); let regex = format!("^{pattern}$");
306 regex_match(®ex, path)
307}
308
309fn regex_match(pattern: &str, text: &str) -> bool {
312 regex::Regex::new(pattern)
313 .map(|re| re.is_match(text))
314 .unwrap_or(false)
315}
316
317fn regex_match_deny(pattern: &str, text: &str) -> bool {
321 regex::Regex::new(pattern)
322 .map(|re| re.is_match(text))
323 .unwrap_or(true) }
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn test_default_has_no_deny_lists() {
332 let caps = AgentCapabilities::default();
333
334 assert!(caps.check_path("src/main.rs").is_ok());
336 assert!(caps.check_path(".env").is_ok());
337 assert!(caps.check_path("/workspace/secrets/key.txt").is_ok());
338 assert!(caps.check_command("any command").is_ok());
339 }
340
341 #[test]
342 fn test_full_access_allows_everything() {
343 let caps = AgentCapabilities::full_access();
344
345 assert!(caps.check_read("/any/path").is_ok());
346 assert!(caps.check_write("/any/path").is_ok());
347 assert!(caps.check_exec("any command").is_ok());
348 }
349
350 #[test]
351 fn test_read_only_cannot_write() {
352 let caps = AgentCapabilities::read_only();
353
354 assert!(caps.check_read("src/main.rs").is_ok());
355 assert!(caps.check_write("src/main.rs").is_err());
356 assert!(caps.check_exec("ls").is_err());
357 }
358
359 #[test]
360 fn test_client_configured_denied_paths() {
361 let caps = AgentCapabilities::full_access().with_denied_paths(vec![
362 "**/.env".into(),
363 "**/.env.*".into(),
364 "**/secrets/**".into(),
365 "**/*.pem".into(),
366 ]);
367
368 assert!(caps.check_path(".env").is_err());
370 assert!(caps.check_path("config/.env.local").is_err());
371 assert!(caps.check_path("app/secrets/key.txt").is_err());
372 assert!(caps.check_path("certs/server.pem").is_err());
373
374 assert!(caps.check_path("/workspace/.env").is_err());
376 assert!(caps.check_path("/workspace/.env.production").is_err());
377 assert!(caps.check_path("/workspace/secrets/key.txt").is_err());
378 assert!(caps.check_path("/workspace/certs/server.pem").is_err());
379
380 assert!(caps.check_path("src/main.rs").is_ok());
382 assert!(caps.check_path("/workspace/src/main.rs").is_ok());
383 assert!(caps.check_path("/workspace/README.md").is_ok());
384 }
385
386 #[test]
387 fn test_allowed_paths_restriction() {
388 let caps = AgentCapabilities::read_only()
389 .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
390
391 assert!(caps.check_path("src/main.rs").is_ok());
392 assert!(caps.check_path("src/lib/utils.rs").is_ok());
393 assert!(caps.check_path("tests/integration.rs").is_ok());
394 assert!(caps.check_path("config/settings.toml").is_err());
395 assert!(caps.check_path("README.md").is_err());
396 }
397
398 #[test]
399 fn test_denied_takes_precedence() {
400 let caps = AgentCapabilities::read_only()
401 .with_denied_paths(vec!["**/secret/**".into()])
402 .with_allowed_paths(vec!["**".into()]);
403
404 assert!(caps.check_path("src/main.rs").is_ok());
405 assert!(caps.check_path("src/secret/key.txt").is_err());
406 }
407
408 #[test]
409 fn test_client_configured_denied_commands() {
410 let caps = AgentCapabilities::full_access()
411 .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
412
413 assert!(caps.check_command("rm -rf /").is_err());
414 assert!(caps.check_command("sudo rm file").is_err());
415
416 assert!(caps.check_command("ls -la").is_ok());
418 assert!(caps.check_command("cargo build").is_ok());
419 assert!(caps.check_command("unzip file.zip 2>/dev/null").is_ok());
420 assert!(
421 caps.check_command("python3 -m markitdown file.pptx")
422 .is_ok()
423 );
424 }
425
426 #[test]
427 fn test_allowed_commands_restriction() {
428 let caps = AgentCapabilities::full_access()
429 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
430
431 assert!(caps.check_command("cargo build").is_ok());
432 assert!(caps.check_command("git status").is_ok());
433 assert!(caps.check_command("ls -la").is_err());
434 assert!(caps.check_command("npm install").is_err());
435 }
436
437 #[test]
438 fn test_glob_matching() {
439 assert!(glob_match("*.rs", "main.rs"));
441 assert!(!glob_match("*.rs", "src/main.rs"));
442
443 assert!(glob_match("**/*.rs", "src/main.rs"));
445 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
446
447 assert!(glob_match("src/**", "src/lib/utils.rs"));
449 assert!(glob_match("src/**", "src/main.rs"));
450
451 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
453 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
454
455 assert!(glob_match("test*", "test_main.rs"));
457 assert!(glob_match("test*.rs", "test_main.rs"));
458
459 assert!(glob_match("**/.env", "/workspace/.env"));
461 assert!(glob_match("**/.env.*", "/workspace/.env.local"));
462 assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
463 assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
464 assert!(glob_match("**/*.key", "/workspace/server.key"));
465 assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
466 assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
467
468 assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
470 assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
471 }
472
473 #[test]
478 fn check_read_disabled_explains_reason() {
479 let caps = AgentCapabilities::none();
480 let err = caps.check_read("src/main.rs").unwrap_err();
481 assert!(err.contains("read access is disabled"), "got: {err}");
482 }
483
484 #[test]
485 fn check_write_disabled_explains_reason() {
486 let caps = AgentCapabilities::read_only();
487 let err = caps.check_write("src/main.rs").unwrap_err();
488 assert!(err.contains("write access is disabled"), "got: {err}");
489 }
490
491 #[test]
492 fn check_exec_disabled_explains_reason() {
493 let caps = AgentCapabilities::read_only();
494 let err = caps.check_exec("ls").unwrap_err();
495 assert!(err.contains("command execution is disabled"), "got: {err}");
496 }
497
498 #[test]
499 fn check_read_denied_path_explains_pattern() {
500 let caps = AgentCapabilities::full_access().with_denied_paths(vec!["**/.env*".into()]);
501 let err = caps.check_read("/workspace/.env.local").unwrap_err();
502 assert!(err.contains("denied pattern"), "got: {err}");
503 assert!(err.contains("**/.env*"), "got: {err}");
504 }
505
506 #[test]
507 fn check_read_not_in_allowed_list() {
508 let caps = AgentCapabilities::full_access().with_allowed_paths(vec!["src/**".into()]);
509 let err = caps.check_read("/workspace/README.md").unwrap_err();
510 assert!(err.contains("not in allowed list"), "got: {err}");
511 assert!(err.contains("src/**"), "got: {err}");
512 }
513
514 #[test]
515 fn check_exec_denied_command_explains_pattern() {
516 let caps = AgentCapabilities::full_access().with_denied_commands(vec![r"^sudo\s".into()]);
517 let err = caps.check_exec("sudo rm -rf /").unwrap_err();
518 assert!(err.contains("denied pattern"), "got: {err}");
519 assert!(err.contains("^sudo\\s"), "got: {err}");
520 }
521
522 #[test]
523 fn check_exec_not_in_allowed_list() {
524 let caps = AgentCapabilities::full_access()
525 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
526 let err = caps.check_exec("npm install").unwrap_err();
527 assert!(err.contains("not in allowed list"), "got: {err}");
528 assert!(err.contains("^cargo "), "got: {err}");
529 }
530
531 #[test]
532 fn check_allowed_operations_return_ok() {
533 let caps = AgentCapabilities::full_access();
534 assert!(caps.check_read("any/path").is_ok());
535 assert!(caps.check_write("any/path").is_ok());
536 assert!(caps.check_exec("any command").is_ok());
537 }
538
539 #[test]
543 fn full_access_allows_common_shell_patterns() {
544 let caps = AgentCapabilities::full_access();
545
546 let commands = [
547 "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
549 r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
551 "cd /workspace && cargo build && cargo test",
553 "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
554 "cargo test 2>&1 | head -50",
556 "cat file.txt | grep pattern | wc -l",
557 "echo 'data' >> /tmp/append.txt",
558 "(cd /tmp && ls -la)",
560 "{ echo a; echo b; } > /tmp/out.txt",
561 "diff <(sort file1) <(sort file2)",
563 "find . -name '*.rs' -exec grep -l 'TODO' {} +",
564 "cargo clippy -- -D warnings",
566 "cargo fmt --check",
567 "git diff --stat HEAD~1",
568 "npm install && npm run build",
569 "python3 -c 'print(\"hello\")'",
570 "grep -rn 'foo(bar)' src/",
572 "echo '$HOME is ~/work'",
573 "ls *.rs",
574 ];
575
576 for cmd in &commands {
577 assert!(
578 caps.check_exec(cmd).is_ok(),
579 "full_access() unexpectedly blocked command: {cmd}"
580 );
581 }
582 }
583
584 #[test]
587 fn full_access_allows_all_paths() {
588 let caps = AgentCapabilities::full_access();
589
590 let paths = [
591 "src/main.rs",
592 ".env",
593 ".env.local",
594 "/tmp/test_caps.rs",
595 "/home/user/.ssh/config",
596 "/workspace/secrets/api_key.txt",
597 "/workspace/certs/server.pem",
598 "Cargo.toml",
599 "node_modules/.package-lock.json",
600 ];
601
602 for path in &paths {
603 assert!(
604 caps.check_read(path).is_ok(),
605 "full_access() unexpectedly blocked read: {path}"
606 );
607 assert!(
608 caps.check_write(path).is_ok(),
609 "full_access() unexpectedly blocked write: {path}"
610 );
611 }
612 }
613
614 #[test]
615 fn invalid_deny_regex_fails_closed() {
616 let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
618
619 assert!(caps.check_command("cargo build").is_err());
621 assert!(caps.check_command("ls").is_err());
622 }
623
624 #[test]
625 fn invalid_allow_regex_fails_open() {
626 let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
628
629 assert!(caps.check_command("cargo build").is_err());
631 }
632
633 #[test]
636 fn default_is_full_access() {
637 let caps = AgentCapabilities::default();
638
639 assert!(caps.check_read("src/main.rs").is_ok());
641 assert!(caps.check_write("src/main.rs").is_ok());
642 assert!(caps.check_exec("ls").is_ok());
643
644 assert!(caps.check_path(".env").is_ok());
646 assert!(caps.check_path("/home/user/.ssh/id_rsa").is_ok());
647 assert!(caps.check_command("sudo rm -rf /").is_ok());
648 }
649}