1use serde::{Deserialize, Serialize};
2use std::sync::OnceLock;
3
4#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct AgentCapabilities {
38 pub read: bool,
40 pub write: bool,
42 pub exec: bool,
44 pub allowed_paths: Vec<String>,
49 pub denied_paths: Vec<String>,
54 pub allowed_commands: Vec<String>,
56 pub denied_commands: Vec<String>,
58 #[serde(skip)]
65 compiled: OnceLock<CompiledPatterns>,
66}
67
68#[derive(Clone, Debug, Default)]
74struct CompiledPatterns {
75 allowed_paths: Vec<Option<regex::Regex>>,
76 denied_paths: Vec<Option<regex::Regex>>,
77 allowed_commands: Vec<Option<regex::Regex>>,
78 denied_commands: Vec<Option<regex::Regex>>,
79}
80
81impl Default for AgentCapabilities {
82 fn default() -> Self {
83 Self::full_access()
84 }
85}
86
87impl AgentCapabilities {
88 #[must_use]
90 pub const fn none() -> Self {
91 Self {
92 read: false,
93 write: false,
94 exec: false,
95 allowed_paths: vec![],
96 denied_paths: vec![],
97 allowed_commands: vec![],
98 denied_commands: vec![],
99 compiled: OnceLock::new(),
100 }
101 }
102
103 #[must_use]
105 pub const fn read_only() -> Self {
106 Self {
107 read: true,
108 write: false,
109 exec: false,
110 allowed_paths: vec![],
111 denied_paths: vec![],
112 allowed_commands: vec![],
113 denied_commands: vec![],
114 compiled: OnceLock::new(),
115 }
116 }
117
118 #[must_use]
120 pub const fn full_access() -> Self {
121 Self {
122 read: true,
123 write: true,
124 exec: true,
125 allowed_paths: vec![],
126 denied_paths: vec![],
127 allowed_commands: vec![],
128 denied_commands: vec![],
129 compiled: OnceLock::new(),
130 }
131 }
132
133 #[must_use]
135 pub const fn with_read(mut self, enabled: bool) -> Self {
136 self.read = enabled;
137 self
138 }
139
140 #[must_use]
142 pub const fn with_write(mut self, enabled: bool) -> Self {
143 self.write = enabled;
144 self
145 }
146
147 #[must_use]
149 pub const fn with_exec(mut self, enabled: bool) -> Self {
150 self.exec = enabled;
151 self
152 }
153
154 #[must_use]
156 pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
157 self.allowed_paths = paths;
158 self.compiled = OnceLock::new();
159 self
160 }
161
162 #[must_use]
164 pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
165 self.denied_paths = paths;
166 self.compiled = OnceLock::new();
167 self
168 }
169
170 #[must_use]
172 pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
173 self.allowed_commands = commands;
174 self.compiled = OnceLock::new();
175 self
176 }
177
178 #[must_use]
180 pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
181 self.denied_commands = commands;
182 self.compiled = OnceLock::new();
183 self
184 }
185
186 pub fn check_read(&self, path: &str) -> Result<(), String> {
193 if !self.read {
194 return Err("read access is disabled".into());
195 }
196 self.check_path(path)
197 }
198
199 pub fn check_write(&self, path: &str) -> Result<(), String> {
206 if !self.write {
207 return Err("write access is disabled".into());
208 }
209 self.check_path(path)
210 }
211
212 pub fn check_exec(&self, command: &str) -> Result<(), String> {
219 if !self.exec {
220 return Err("command execution is disabled".into());
221 }
222 self.check_command(command)
223 }
224
225 #[must_use]
227 pub fn can_read(&self, path: &str) -> bool {
228 self.check_read(path).is_ok()
229 }
230
231 #[must_use]
233 pub fn can_write(&self, path: &str) -> bool {
234 self.check_write(path).is_ok()
235 }
236
237 #[must_use]
239 pub fn can_exec(&self, command: &str) -> bool {
240 self.check_exec(command).is_ok()
241 }
242
243 pub fn check_path(&self, path: &str) -> Result<(), String> {
251 let compiled = self.compiled();
252
253 for (pattern, regex) in self.denied_paths.iter().zip(&compiled.denied_paths) {
256 if regex.as_ref().is_some_and(|re| re.is_match(path)) {
257 return Err(format!("path matches denied pattern '{pattern}'"));
258 }
259 }
260
261 if self.allowed_paths.is_empty() {
263 return Ok(());
264 }
265
266 for regex in &compiled.allowed_paths {
268 if regex.as_ref().is_some_and(|re| re.is_match(path)) {
269 return Ok(());
270 }
271 }
272
273 Err(format!(
274 "path not in allowed list (allowed: [{}])",
275 self.allowed_paths.join(", ")
276 ))
277 }
278
279 pub fn check_command(&self, command: &str) -> Result<(), String> {
298 let compiled = self.compiled();
299
300 for (pattern, regex) in self.denied_commands.iter().zip(&compiled.denied_commands) {
304 if regex.as_ref().is_none_or(|re| re.is_match(command)) {
305 return Err(format!("command matches denied pattern '{pattern}'"));
306 }
307 }
308
309 if self.allowed_commands.is_empty() {
311 return Ok(());
312 }
313
314 for regex in &compiled.allowed_commands {
317 if regex.as_ref().is_some_and(|re| re.is_match(command)) {
318 return Ok(());
319 }
320 }
321
322 Err(format!(
323 "command not in allowed list (allowed: [{}])",
324 self.allowed_commands.join(", ")
325 ))
326 }
327
328 fn compiled(&self) -> &CompiledPatterns {
330 self.compiled.get_or_init(|| CompiledPatterns {
331 allowed_paths: self
332 .allowed_paths
333 .iter()
334 .map(|p| compile_glob(p.as_str()))
335 .collect(),
336 denied_paths: self
337 .denied_paths
338 .iter()
339 .map(|p| compile_glob(p.as_str()))
340 .collect(),
341 allowed_commands: self
342 .allowed_commands
343 .iter()
344 .map(|p| regex::Regex::new(p.as_str()).ok())
345 .collect(),
346 denied_commands: self
347 .denied_commands
348 .iter()
349 .map(|p| regex::Regex::new(p.as_str()).ok())
350 .collect(),
351 })
352 }
353}
354
355fn glob_to_regex(pattern: &str) -> String {
362 if pattern == "**" {
364 return "^.*$".to_string();
365 }
366
367 let mut escaped = String::with_capacity(pattern.len());
369 for c in pattern.chars() {
370 match c {
371 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
372 escaped.push('\\');
373 escaped.push(c);
374 }
375 _ => escaped.push(c),
376 }
377 }
378
379 let body = escaped
380 .replace("**/", "\x00") .replace("/**", "\x01") .replace('*', "[^/]*") .replace('\x00', "(.*/)?") .replace('\x01', "(/.*)?"); format!("^{body}$")
387}
388
389fn compile_glob(pattern: &str) -> Option<regex::Regex> {
391 regex::Regex::new(&glob_to_regex(pattern)).ok()
392}
393
394#[cfg(test)]
399fn glob_match(pattern: &str, path: &str) -> bool {
400 compile_glob(pattern).is_some_and(|re| re.is_match(path))
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_default_has_no_deny_lists() {
409 let caps = AgentCapabilities::default();
410
411 assert!(caps.check_path("src/main.rs").is_ok());
413 assert!(caps.check_path(".env").is_ok());
414 assert!(caps.check_path("/workspace/secrets/key.txt").is_ok());
415 assert!(caps.check_command("any command").is_ok());
416 }
417
418 #[test]
419 fn test_full_access_allows_everything() {
420 let caps = AgentCapabilities::full_access();
421
422 assert!(caps.check_read("/any/path").is_ok());
423 assert!(caps.check_write("/any/path").is_ok());
424 assert!(caps.check_exec("any command").is_ok());
425 }
426
427 #[test]
428 fn test_read_only_cannot_write() {
429 let caps = AgentCapabilities::read_only();
430
431 assert!(caps.check_read("src/main.rs").is_ok());
432 assert!(caps.check_write("src/main.rs").is_err());
433 assert!(caps.check_exec("ls").is_err());
434 }
435
436 #[test]
437 fn test_client_configured_denied_paths() {
438 let caps = AgentCapabilities::full_access().with_denied_paths(vec![
439 "**/.env".into(),
440 "**/.env.*".into(),
441 "**/secrets/**".into(),
442 "**/*.pem".into(),
443 ]);
444
445 assert!(caps.check_path(".env").is_err());
447 assert!(caps.check_path("config/.env.local").is_err());
448 assert!(caps.check_path("app/secrets/key.txt").is_err());
449 assert!(caps.check_path("certs/server.pem").is_err());
450
451 assert!(caps.check_path("/workspace/.env").is_err());
453 assert!(caps.check_path("/workspace/.env.production").is_err());
454 assert!(caps.check_path("/workspace/secrets/key.txt").is_err());
455 assert!(caps.check_path("/workspace/certs/server.pem").is_err());
456
457 assert!(caps.check_path("src/main.rs").is_ok());
459 assert!(caps.check_path("/workspace/src/main.rs").is_ok());
460 assert!(caps.check_path("/workspace/README.md").is_ok());
461 }
462
463 #[test]
464 fn test_allowed_paths_restriction() {
465 let caps = AgentCapabilities::read_only()
466 .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
467
468 assert!(caps.check_path("src/main.rs").is_ok());
469 assert!(caps.check_path("src/lib/utils.rs").is_ok());
470 assert!(caps.check_path("tests/integration.rs").is_ok());
471 assert!(caps.check_path("config/settings.toml").is_err());
472 assert!(caps.check_path("README.md").is_err());
473 }
474
475 #[test]
476 fn allowed_paths_match_resolved_absolute_paths() {
477 let caps = AgentCapabilities::read_only().with_allowed_paths(vec!["**/src/**".into()]);
480
481 assert!(caps.check_read("/workspace/src/main.rs").is_ok());
482 assert!(caps.check_read("/workspace/src/lib/mod.rs").is_ok());
483 assert!(caps.can_read("/Users/dev/project/src/deep/nested.rs"));
484
485 assert!(caps.check_read("/workspace/README.md").is_err());
487 assert!(caps.check_read("/workspace/tests/it.rs").is_err());
488 }
489
490 #[test]
491 fn rebuilding_patterns_invalidates_compiled_cache() {
492 let caps = AgentCapabilities::full_access();
494 assert!(caps.check_path("/workspace/.env").is_ok());
495
496 let restricted = caps.with_denied_paths(vec!["**/.env".into()]);
499 assert!(restricted.check_path("/workspace/.env").is_err());
500 assert!(restricted.check_path("/workspace/src/main.rs").is_ok());
501 assert!(restricted.check_path("/workspace/.env").is_err());
503 }
504
505 #[test]
506 fn test_denied_takes_precedence() {
507 let caps = AgentCapabilities::read_only()
508 .with_denied_paths(vec!["**/secret/**".into()])
509 .with_allowed_paths(vec!["**".into()]);
510
511 assert!(caps.check_path("src/main.rs").is_ok());
512 assert!(caps.check_path("src/secret/key.txt").is_err());
513 }
514
515 #[test]
516 fn test_client_configured_denied_commands() {
517 let caps = AgentCapabilities::full_access()
518 .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
519
520 assert!(caps.check_command("rm -rf /").is_err());
521 assert!(caps.check_command("sudo rm file").is_err());
522
523 assert!(caps.check_command("ls -la").is_ok());
525 assert!(caps.check_command("cargo build").is_ok());
526 assert!(caps.check_command("unzip file.zip 2>/dev/null").is_ok());
527 assert!(
528 caps.check_command("python3 -m markitdown file.pptx")
529 .is_ok()
530 );
531 }
532
533 #[test]
534 fn test_allowed_commands_restriction() {
535 let caps = AgentCapabilities::full_access()
536 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
537
538 assert!(caps.check_command("cargo build").is_ok());
539 assert!(caps.check_command("git status").is_ok());
540 assert!(caps.check_command("ls -la").is_err());
541 assert!(caps.check_command("npm install").is_err());
542 }
543
544 #[test]
545 fn test_glob_matching() {
546 assert!(glob_match("*.rs", "main.rs"));
548 assert!(!glob_match("*.rs", "src/main.rs"));
549
550 assert!(glob_match("**/*.rs", "src/main.rs"));
552 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
553
554 assert!(glob_match("src/**", "src/lib/utils.rs"));
556 assert!(glob_match("src/**", "src/main.rs"));
557
558 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
560 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
561
562 assert!(glob_match("test*", "test_main.rs"));
564 assert!(glob_match("test*.rs", "test_main.rs"));
565
566 assert!(glob_match("**/.env", "/workspace/.env"));
568 assert!(glob_match("**/.env.*", "/workspace/.env.local"));
569 assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
570 assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
571 assert!(glob_match("**/*.key", "/workspace/server.key"));
572 assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
573 assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
574
575 assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
577 assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
578 }
579
580 #[test]
585 fn check_read_disabled_explains_reason() {
586 let caps = AgentCapabilities::none();
587 let err = caps.check_read("src/main.rs").unwrap_err();
588 assert!(err.contains("read access is disabled"), "got: {err}");
589 }
590
591 #[test]
592 fn check_write_disabled_explains_reason() {
593 let caps = AgentCapabilities::read_only();
594 let err = caps.check_write("src/main.rs").unwrap_err();
595 assert!(err.contains("write access is disabled"), "got: {err}");
596 }
597
598 #[test]
599 fn check_exec_disabled_explains_reason() {
600 let caps = AgentCapabilities::read_only();
601 let err = caps.check_exec("ls").unwrap_err();
602 assert!(err.contains("command execution is disabled"), "got: {err}");
603 }
604
605 #[test]
606 fn check_read_denied_path_explains_pattern() {
607 let caps = AgentCapabilities::full_access().with_denied_paths(vec!["**/.env*".into()]);
608 let err = caps.check_read("/workspace/.env.local").unwrap_err();
609 assert!(err.contains("denied pattern"), "got: {err}");
610 assert!(err.contains("**/.env*"), "got: {err}");
611 }
612
613 #[test]
614 fn check_read_not_in_allowed_list() {
615 let caps = AgentCapabilities::full_access().with_allowed_paths(vec!["src/**".into()]);
616 let err = caps.check_read("/workspace/README.md").unwrap_err();
617 assert!(err.contains("not in allowed list"), "got: {err}");
618 assert!(err.contains("src/**"), "got: {err}");
619 }
620
621 #[test]
622 fn check_exec_denied_command_explains_pattern() {
623 let caps = AgentCapabilities::full_access().with_denied_commands(vec![r"^sudo\s".into()]);
624 let err = caps.check_exec("sudo rm -rf /").unwrap_err();
625 assert!(err.contains("denied pattern"), "got: {err}");
626 assert!(err.contains("^sudo\\s"), "got: {err}");
627 }
628
629 #[test]
630 fn check_exec_not_in_allowed_list() {
631 let caps = AgentCapabilities::full_access()
632 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
633 let err = caps.check_exec("npm install").unwrap_err();
634 assert!(err.contains("not in allowed list"), "got: {err}");
635 assert!(err.contains("^cargo "), "got: {err}");
636 }
637
638 #[test]
639 fn check_allowed_operations_return_ok() {
640 let caps = AgentCapabilities::full_access();
641 assert!(caps.check_read("any/path").is_ok());
642 assert!(caps.check_write("any/path").is_ok());
643 assert!(caps.check_exec("any command").is_ok());
644 }
645
646 #[test]
650 fn full_access_allows_common_shell_patterns() {
651 let caps = AgentCapabilities::full_access();
652
653 let commands = [
654 "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
656 r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
658 "cd /workspace && cargo build && cargo test",
660 "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
661 "cargo test 2>&1 | head -50",
663 "cat file.txt | grep pattern | wc -l",
664 "echo 'data' >> /tmp/append.txt",
665 "(cd /tmp && ls -la)",
667 "{ echo a; echo b; } > /tmp/out.txt",
668 "diff <(sort file1) <(sort file2)",
670 "find . -name '*.rs' -exec grep -l 'TODO' {} +",
671 "cargo clippy -- -D warnings",
673 "cargo fmt --check",
674 "git diff --stat HEAD~1",
675 "npm install && npm run build",
676 "python3 -c 'print(\"hello\")'",
677 "grep -rn 'foo(bar)' src/",
679 "echo '$HOME is ~/work'",
680 "ls *.rs",
681 ];
682
683 for cmd in &commands {
684 assert!(
685 caps.check_exec(cmd).is_ok(),
686 "full_access() unexpectedly blocked command: {cmd}"
687 );
688 }
689 }
690
691 #[test]
694 fn full_access_allows_all_paths() {
695 let caps = AgentCapabilities::full_access();
696
697 let paths = [
698 "src/main.rs",
699 ".env",
700 ".env.local",
701 "/tmp/test_caps.rs",
702 "/home/user/.ssh/config",
703 "/workspace/secrets/api_key.txt",
704 "/workspace/certs/server.pem",
705 "Cargo.toml",
706 "node_modules/.package-lock.json",
707 ];
708
709 for path in &paths {
710 assert!(
711 caps.check_read(path).is_ok(),
712 "full_access() unexpectedly blocked read: {path}"
713 );
714 assert!(
715 caps.check_write(path).is_ok(),
716 "full_access() unexpectedly blocked write: {path}"
717 );
718 }
719 }
720
721 #[test]
722 fn invalid_deny_regex_fails_closed() {
723 let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
725
726 assert!(caps.check_command("cargo build").is_err());
728 assert!(caps.check_command("ls").is_err());
729 }
730
731 #[test]
732 fn invalid_allow_regex_fails_open() {
733 let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
735
736 assert!(caps.check_command("cargo build").is_err());
738 }
739
740 #[test]
743 fn default_is_full_access() {
744 let caps = AgentCapabilities::default();
745
746 assert!(caps.check_read("src/main.rs").is_ok());
748 assert!(caps.check_write("src/main.rs").is_ok());
749 assert!(caps.check_exec("ls").is_ok());
750
751 assert!(caps.check_path(".env").is_ok());
753 assert!(caps.check_path("/home/user/.ssh/id_rsa").is_ok());
754 assert!(caps.check_command("sudo rm -rf /").is_ok());
755 }
756}