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> {
238 for pattern in &self.denied_commands {
240 if regex_match(pattern, command) {
241 return Err(format!("command matches denied pattern '{pattern}'"));
242 }
243 }
244
245 if self.allowed_commands.is_empty() {
247 return Ok(());
248 }
249
250 for pattern in &self.allowed_commands {
252 if regex_match(pattern, command) {
253 return Ok(());
254 }
255 }
256
257 Err(format!(
258 "command not in allowed list (allowed: [{}])",
259 self.allowed_commands.join(", ")
260 ))
261 }
262}
263
264fn glob_match(pattern: &str, path: &str) -> bool {
266 if pattern == "**" {
268 return true; }
270
271 let mut escaped = String::new();
273 for c in pattern.chars() {
274 match c {
275 '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
276 escaped.push('\\');
277 escaped.push(c);
278 }
279 _ => escaped.push(c),
280 }
281 }
282
283 let pattern = escaped
288 .replace("**/", "\x00") .replace("/**", "\x01") .replace('*', "[^/]*") .replace('\x00', "(.*/)?") .replace('\x01', "(/.*)?"); let regex = format!("^{pattern}$");
295 regex_match(®ex, path)
296}
297
298fn regex_match(pattern: &str, text: &str) -> bool {
300 regex::Regex::new(pattern)
301 .map(|re| re.is_match(text))
302 .unwrap_or(false)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_default_has_no_deny_lists() {
311 let caps = AgentCapabilities::default();
312
313 assert!(caps.check_path("src/main.rs").is_ok());
315 assert!(caps.check_path(".env").is_ok());
316 assert!(caps.check_path("/workspace/secrets/key.txt").is_ok());
317 assert!(caps.check_command("any command").is_ok());
318 }
319
320 #[test]
321 fn test_full_access_allows_everything() {
322 let caps = AgentCapabilities::full_access();
323
324 assert!(caps.check_read("/any/path").is_ok());
325 assert!(caps.check_write("/any/path").is_ok());
326 assert!(caps.check_exec("any command").is_ok());
327 }
328
329 #[test]
330 fn test_read_only_cannot_write() {
331 let caps = AgentCapabilities::read_only();
332
333 assert!(caps.check_read("src/main.rs").is_ok());
334 assert!(caps.check_write("src/main.rs").is_err());
335 assert!(caps.check_exec("ls").is_err());
336 }
337
338 #[test]
339 fn test_client_configured_denied_paths() {
340 let caps = AgentCapabilities::full_access().with_denied_paths(vec![
341 "**/.env".into(),
342 "**/.env.*".into(),
343 "**/secrets/**".into(),
344 "**/*.pem".into(),
345 ]);
346
347 assert!(caps.check_path(".env").is_err());
349 assert!(caps.check_path("config/.env.local").is_err());
350 assert!(caps.check_path("app/secrets/key.txt").is_err());
351 assert!(caps.check_path("certs/server.pem").is_err());
352
353 assert!(caps.check_path("/workspace/.env").is_err());
355 assert!(caps.check_path("/workspace/.env.production").is_err());
356 assert!(caps.check_path("/workspace/secrets/key.txt").is_err());
357 assert!(caps.check_path("/workspace/certs/server.pem").is_err());
358
359 assert!(caps.check_path("src/main.rs").is_ok());
361 assert!(caps.check_path("/workspace/src/main.rs").is_ok());
362 assert!(caps.check_path("/workspace/README.md").is_ok());
363 }
364
365 #[test]
366 fn test_allowed_paths_restriction() {
367 let caps = AgentCapabilities::read_only()
368 .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
369
370 assert!(caps.check_path("src/main.rs").is_ok());
371 assert!(caps.check_path("src/lib/utils.rs").is_ok());
372 assert!(caps.check_path("tests/integration.rs").is_ok());
373 assert!(caps.check_path("config/settings.toml").is_err());
374 assert!(caps.check_path("README.md").is_err());
375 }
376
377 #[test]
378 fn test_denied_takes_precedence() {
379 let caps = AgentCapabilities::read_only()
380 .with_denied_paths(vec!["**/secret/**".into()])
381 .with_allowed_paths(vec!["**".into()]);
382
383 assert!(caps.check_path("src/main.rs").is_ok());
384 assert!(caps.check_path("src/secret/key.txt").is_err());
385 }
386
387 #[test]
388 fn test_client_configured_denied_commands() {
389 let caps = AgentCapabilities::full_access()
390 .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
391
392 assert!(caps.check_command("rm -rf /").is_err());
393 assert!(caps.check_command("sudo rm file").is_err());
394
395 assert!(caps.check_command("ls -la").is_ok());
397 assert!(caps.check_command("cargo build").is_ok());
398 assert!(caps.check_command("unzip file.zip 2>/dev/null").is_ok());
399 assert!(
400 caps.check_command("python3 -m markitdown file.pptx")
401 .is_ok()
402 );
403 }
404
405 #[test]
406 fn test_allowed_commands_restriction() {
407 let caps = AgentCapabilities::full_access()
408 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
409
410 assert!(caps.check_command("cargo build").is_ok());
411 assert!(caps.check_command("git status").is_ok());
412 assert!(caps.check_command("ls -la").is_err());
413 assert!(caps.check_command("npm install").is_err());
414 }
415
416 #[test]
417 fn test_glob_matching() {
418 assert!(glob_match("*.rs", "main.rs"));
420 assert!(!glob_match("*.rs", "src/main.rs"));
421
422 assert!(glob_match("**/*.rs", "src/main.rs"));
424 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
425
426 assert!(glob_match("src/**", "src/lib/utils.rs"));
428 assert!(glob_match("src/**", "src/main.rs"));
429
430 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
432 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
433
434 assert!(glob_match("test*", "test_main.rs"));
436 assert!(glob_match("test*.rs", "test_main.rs"));
437
438 assert!(glob_match("**/.env", "/workspace/.env"));
440 assert!(glob_match("**/.env.*", "/workspace/.env.local"));
441 assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
442 assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
443 assert!(glob_match("**/*.key", "/workspace/server.key"));
444 assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
445 assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
446
447 assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
449 assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
450 }
451
452 #[test]
457 fn check_read_disabled_explains_reason() {
458 let caps = AgentCapabilities::none();
459 let err = caps.check_read("src/main.rs").unwrap_err();
460 assert!(err.contains("read access is disabled"), "got: {err}");
461 }
462
463 #[test]
464 fn check_write_disabled_explains_reason() {
465 let caps = AgentCapabilities::read_only();
466 let err = caps.check_write("src/main.rs").unwrap_err();
467 assert!(err.contains("write access is disabled"), "got: {err}");
468 }
469
470 #[test]
471 fn check_exec_disabled_explains_reason() {
472 let caps = AgentCapabilities::read_only();
473 let err = caps.check_exec("ls").unwrap_err();
474 assert!(err.contains("command execution is disabled"), "got: {err}");
475 }
476
477 #[test]
478 fn check_read_denied_path_explains_pattern() {
479 let caps = AgentCapabilities::full_access().with_denied_paths(vec!["**/.env*".into()]);
480 let err = caps.check_read("/workspace/.env.local").unwrap_err();
481 assert!(err.contains("denied pattern"), "got: {err}");
482 assert!(err.contains("**/.env*"), "got: {err}");
483 }
484
485 #[test]
486 fn check_read_not_in_allowed_list() {
487 let caps = AgentCapabilities::full_access().with_allowed_paths(vec!["src/**".into()]);
488 let err = caps.check_read("/workspace/README.md").unwrap_err();
489 assert!(err.contains("not in allowed list"), "got: {err}");
490 assert!(err.contains("src/**"), "got: {err}");
491 }
492
493 #[test]
494 fn check_exec_denied_command_explains_pattern() {
495 let caps = AgentCapabilities::full_access().with_denied_commands(vec![r"^sudo\s".into()]);
496 let err = caps.check_exec("sudo rm -rf /").unwrap_err();
497 assert!(err.contains("denied pattern"), "got: {err}");
498 assert!(err.contains("^sudo\\s"), "got: {err}");
499 }
500
501 #[test]
502 fn check_exec_not_in_allowed_list() {
503 let caps = AgentCapabilities::full_access()
504 .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
505 let err = caps.check_exec("npm install").unwrap_err();
506 assert!(err.contains("not in allowed list"), "got: {err}");
507 assert!(err.contains("^cargo "), "got: {err}");
508 }
509
510 #[test]
511 fn check_allowed_operations_return_ok() {
512 let caps = AgentCapabilities::full_access();
513 assert!(caps.check_read("any/path").is_ok());
514 assert!(caps.check_write("any/path").is_ok());
515 assert!(caps.check_exec("any command").is_ok());
516 }
517
518 #[test]
522 fn full_access_allows_common_shell_patterns() {
523 let caps = AgentCapabilities::full_access();
524
525 let commands = [
526 "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
528 r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
530 "cd /workspace && cargo build && cargo test",
532 "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
533 "cargo test 2>&1 | head -50",
535 "cat file.txt | grep pattern | wc -l",
536 "echo 'data' >> /tmp/append.txt",
537 "(cd /tmp && ls -la)",
539 "{ echo a; echo b; } > /tmp/out.txt",
540 "diff <(sort file1) <(sort file2)",
542 "find . -name '*.rs' -exec grep -l 'TODO' {} +",
543 "cargo clippy -- -D warnings",
545 "cargo fmt --check",
546 "git diff --stat HEAD~1",
547 "npm install && npm run build",
548 "python3 -c 'print(\"hello\")'",
549 "grep -rn 'foo(bar)' src/",
551 "echo '$HOME is ~/work'",
552 "ls *.rs",
553 ];
554
555 for cmd in &commands {
556 assert!(
557 caps.check_exec(cmd).is_ok(),
558 "full_access() unexpectedly blocked command: {cmd}"
559 );
560 }
561 }
562
563 #[test]
566 fn full_access_allows_all_paths() {
567 let caps = AgentCapabilities::full_access();
568
569 let paths = [
570 "src/main.rs",
571 ".env",
572 ".env.local",
573 "/tmp/test_caps.rs",
574 "/home/user/.ssh/config",
575 "/workspace/secrets/api_key.txt",
576 "/workspace/certs/server.pem",
577 "Cargo.toml",
578 "node_modules/.package-lock.json",
579 ];
580
581 for path in &paths {
582 assert!(
583 caps.check_read(path).is_ok(),
584 "full_access() unexpectedly blocked read: {path}"
585 );
586 assert!(
587 caps.check_write(path).is_ok(),
588 "full_access() unexpectedly blocked write: {path}"
589 );
590 }
591 }
592
593 #[test]
596 fn default_is_full_access() {
597 let caps = AgentCapabilities::default();
598
599 assert!(caps.check_read("src/main.rs").is_ok());
601 assert!(caps.check_write("src/main.rs").is_ok());
602 assert!(caps.check_exec("ls").is_ok());
603
604 assert!(caps.check_path(".env").is_ok());
606 assert!(caps.check_path("/home/user/.ssh/id_rsa").is_ok());
607 assert!(caps.check_command("sudo rm -rf /").is_ok());
608 }
609}