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).is_ok_and(|re| re.is_match(text))
313}
314
315fn regex_match_deny(pattern: &str, text: &str) -> bool {
319 regex::Regex::new(pattern).map_or(true, |re| re.is_match(text))
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_default_has_no_deny_lists() {
328 let caps = AgentCapabilities::default();
329
330 assert!(caps.check_path("src/main.rs").is_ok());
332 assert!(caps.check_path(".env").is_ok());
333 assert!(caps.check_path("/workspace/secrets/key.txt").is_ok());
334 assert!(caps.check_command("any command").is_ok());
335 }
336
337 #[test]
338 fn test_full_access_allows_everything() {
339 let caps = AgentCapabilities::full_access();
340
341 assert!(caps.check_read("/any/path").is_ok());
342 assert!(caps.check_write("/any/path").is_ok());
343 assert!(caps.check_exec("any command").is_ok());
344 }
345
346 #[test]
347 fn test_read_only_cannot_write() {
348 let caps = AgentCapabilities::read_only();
349
350 assert!(caps.check_read("src/main.rs").is_ok());
351 assert!(caps.check_write("src/main.rs").is_err());
352 assert!(caps.check_exec("ls").is_err());
353 }
354
355 #[test]
356 fn test_client_configured_denied_paths() {
357 let caps = AgentCapabilities::full_access().with_denied_paths(vec![
358 "**/.env".into(),
359 "**/.env.*".into(),
360 "**/secrets/**".into(),
361 "**/*.pem".into(),
362 ]);
363
364 assert!(caps.check_path(".env").is_err());
366 assert!(caps.check_path("config/.env.local").is_err());
367 assert!(caps.check_path("app/secrets/key.txt").is_err());
368 assert!(caps.check_path("certs/server.pem").is_err());
369
370 assert!(caps.check_path("/workspace/.env").is_err());
372 assert!(caps.check_path("/workspace/.env.production").is_err());
373 assert!(caps.check_path("/workspace/secrets/key.txt").is_err());
374 assert!(caps.check_path("/workspace/certs/server.pem").is_err());
375
376 assert!(caps.check_path("src/main.rs").is_ok());
378 assert!(caps.check_path("/workspace/src/main.rs").is_ok());
379 assert!(caps.check_path("/workspace/README.md").is_ok());
380 }
381
382 #[test]
383 fn test_allowed_paths_restriction() {
384 let caps = AgentCapabilities::read_only()
385 .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
386
387 assert!(caps.check_path("src/main.rs").is_ok());
388 assert!(caps.check_path("src/lib/utils.rs").is_ok());
389 assert!(caps.check_path("tests/integration.rs").is_ok());
390 assert!(caps.check_path("config/settings.toml").is_err());
391 assert!(caps.check_path("README.md").is_err());
392 }
393
394 #[test]
395 fn test_denied_takes_precedence() {
396 let caps = AgentCapabilities::read_only()
397 .with_denied_paths(vec!["**/secret/**".into()])
398 .with_allowed_paths(vec!["**".into()]);
399
400 assert!(caps.check_path("src/main.rs").is_ok());
401 assert!(caps.check_path("src/secret/key.txt").is_err());
402 }
403
404 #[test]
405 fn test_client_configured_denied_commands() {
406 let caps = AgentCapabilities::full_access()
407 .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
408
409 assert!(caps.check_command("rm -rf /").is_err());
410 assert!(caps.check_command("sudo rm file").is_err());
411
412 assert!(caps.check_command("ls -la").is_ok());
414 assert!(caps.check_command("cargo build").is_ok());
415 assert!(caps.check_command("unzip file.zip 2>/dev/null").is_ok());
416 assert!(
417 caps.check_command("python3 -m markitdown file.pptx")
418 .is_ok()
419 );
420 }
421
422 #[test]
423 fn test_allowed_commands_restriction() {
424 let caps = AgentCapabilities::full_access()
425 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
426
427 assert!(caps.check_command("cargo build").is_ok());
428 assert!(caps.check_command("git status").is_ok());
429 assert!(caps.check_command("ls -la").is_err());
430 assert!(caps.check_command("npm install").is_err());
431 }
432
433 #[test]
434 fn test_glob_matching() {
435 assert!(glob_match("*.rs", "main.rs"));
437 assert!(!glob_match("*.rs", "src/main.rs"));
438
439 assert!(glob_match("**/*.rs", "src/main.rs"));
441 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
442
443 assert!(glob_match("src/**", "src/lib/utils.rs"));
445 assert!(glob_match("src/**", "src/main.rs"));
446
447 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
449 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
450
451 assert!(glob_match("test*", "test_main.rs"));
453 assert!(glob_match("test*.rs", "test_main.rs"));
454
455 assert!(glob_match("**/.env", "/workspace/.env"));
457 assert!(glob_match("**/.env.*", "/workspace/.env.local"));
458 assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
459 assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
460 assert!(glob_match("**/*.key", "/workspace/server.key"));
461 assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
462 assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
463
464 assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
466 assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
467 }
468
469 #[test]
474 fn check_read_disabled_explains_reason() {
475 let caps = AgentCapabilities::none();
476 let err = caps.check_read("src/main.rs").unwrap_err();
477 assert!(err.contains("read access is disabled"), "got: {err}");
478 }
479
480 #[test]
481 fn check_write_disabled_explains_reason() {
482 let caps = AgentCapabilities::read_only();
483 let err = caps.check_write("src/main.rs").unwrap_err();
484 assert!(err.contains("write access is disabled"), "got: {err}");
485 }
486
487 #[test]
488 fn check_exec_disabled_explains_reason() {
489 let caps = AgentCapabilities::read_only();
490 let err = caps.check_exec("ls").unwrap_err();
491 assert!(err.contains("command execution is disabled"), "got: {err}");
492 }
493
494 #[test]
495 fn check_read_denied_path_explains_pattern() {
496 let caps = AgentCapabilities::full_access().with_denied_paths(vec!["**/.env*".into()]);
497 let err = caps.check_read("/workspace/.env.local").unwrap_err();
498 assert!(err.contains("denied pattern"), "got: {err}");
499 assert!(err.contains("**/.env*"), "got: {err}");
500 }
501
502 #[test]
503 fn check_read_not_in_allowed_list() {
504 let caps = AgentCapabilities::full_access().with_allowed_paths(vec!["src/**".into()]);
505 let err = caps.check_read("/workspace/README.md").unwrap_err();
506 assert!(err.contains("not in allowed list"), "got: {err}");
507 assert!(err.contains("src/**"), "got: {err}");
508 }
509
510 #[test]
511 fn check_exec_denied_command_explains_pattern() {
512 let caps = AgentCapabilities::full_access().with_denied_commands(vec![r"^sudo\s".into()]);
513 let err = caps.check_exec("sudo rm -rf /").unwrap_err();
514 assert!(err.contains("denied pattern"), "got: {err}");
515 assert!(err.contains("^sudo\\s"), "got: {err}");
516 }
517
518 #[test]
519 fn check_exec_not_in_allowed_list() {
520 let caps = AgentCapabilities::full_access()
521 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
522 let err = caps.check_exec("npm install").unwrap_err();
523 assert!(err.contains("not in allowed list"), "got: {err}");
524 assert!(err.contains("^cargo "), "got: {err}");
525 }
526
527 #[test]
528 fn check_allowed_operations_return_ok() {
529 let caps = AgentCapabilities::full_access();
530 assert!(caps.check_read("any/path").is_ok());
531 assert!(caps.check_write("any/path").is_ok());
532 assert!(caps.check_exec("any command").is_ok());
533 }
534
535 #[test]
539 fn full_access_allows_common_shell_patterns() {
540 let caps = AgentCapabilities::full_access();
541
542 let commands = [
543 "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
545 r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
547 "cd /workspace && cargo build && cargo test",
549 "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
550 "cargo test 2>&1 | head -50",
552 "cat file.txt | grep pattern | wc -l",
553 "echo 'data' >> /tmp/append.txt",
554 "(cd /tmp && ls -la)",
556 "{ echo a; echo b; } > /tmp/out.txt",
557 "diff <(sort file1) <(sort file2)",
559 "find . -name '*.rs' -exec grep -l 'TODO' {} +",
560 "cargo clippy -- -D warnings",
562 "cargo fmt --check",
563 "git diff --stat HEAD~1",
564 "npm install && npm run build",
565 "python3 -c 'print(\"hello\")'",
566 "grep -rn 'foo(bar)' src/",
568 "echo '$HOME is ~/work'",
569 "ls *.rs",
570 ];
571
572 for cmd in &commands {
573 assert!(
574 caps.check_exec(cmd).is_ok(),
575 "full_access() unexpectedly blocked command: {cmd}"
576 );
577 }
578 }
579
580 #[test]
583 fn full_access_allows_all_paths() {
584 let caps = AgentCapabilities::full_access();
585
586 let paths = [
587 "src/main.rs",
588 ".env",
589 ".env.local",
590 "/tmp/test_caps.rs",
591 "/home/user/.ssh/config",
592 "/workspace/secrets/api_key.txt",
593 "/workspace/certs/server.pem",
594 "Cargo.toml",
595 "node_modules/.package-lock.json",
596 ];
597
598 for path in &paths {
599 assert!(
600 caps.check_read(path).is_ok(),
601 "full_access() unexpectedly blocked read: {path}"
602 );
603 assert!(
604 caps.check_write(path).is_ok(),
605 "full_access() unexpectedly blocked write: {path}"
606 );
607 }
608 }
609
610 #[test]
611 fn invalid_deny_regex_fails_closed() {
612 let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
614
615 assert!(caps.check_command("cargo build").is_err());
617 assert!(caps.check_command("ls").is_err());
618 }
619
620 #[test]
621 fn invalid_allow_regex_fails_open() {
622 let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
624
625 assert!(caps.check_command("cargo build").is_err());
627 }
628
629 #[test]
632 fn default_is_full_access() {
633 let caps = AgentCapabilities::default();
634
635 assert!(caps.check_read("src/main.rs").is_ok());
637 assert!(caps.check_write("src/main.rs").is_ok());
638 assert!(caps.check_exec("ls").is_ok());
639
640 assert!(caps.check_path(".env").is_ok());
642 assert!(caps.check_path("/home/user/.ssh/id_rsa").is_ok());
643 assert!(caps.check_command("sudo rm -rf /").is_ok());
644 }
645}