Skip to main content

agent_sdk/
capabilities.rs

1use serde::{Deserialize, Serialize};
2
3/// Capabilities that control what the agent can do.
4///
5/// This provides a security model for primitive tools (Read, Write, Grep, Glob, Bash).
6/// Paths are matched using glob patterns, commands using regex patterns.
7///
8/// By default, everything is allowed — the SDK is unopinionated and leaves
9/// security policy to the client. Use the builder methods to configure restrictions.
10///
11/// # Example
12///
13/// ```rust
14/// use agent_sdk::AgentCapabilities;
15///
16/// // Read-only agent that can only access src/ directory
17/// let caps = AgentCapabilities::read_only()
18///     .with_allowed_paths(vec!["src/**/*".into()]);
19///
20/// // Full access agent with some restrictions
21/// let caps = AgentCapabilities::full_access()
22///     .with_denied_paths(vec!["**/.env*".into(), "**/secrets/**".into()]);
23/// ```
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct AgentCapabilities {
26    /// Can read files
27    pub read: bool,
28    /// Can write/edit files
29    pub write: bool,
30    /// Can execute shell commands
31    pub exec: bool,
32    /// Allowed path patterns (glob). Empty means all paths allowed.
33    pub allowed_paths: Vec<String>,
34    /// Denied path patterns (glob). Takes precedence over `allowed_paths`.
35    pub denied_paths: Vec<String>,
36    /// Allowed commands (regex patterns). Empty means all commands allowed when `exec=true`.
37    pub allowed_commands: Vec<String>,
38    /// Denied commands (regex patterns). Takes precedence over `allowed_commands`.
39    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    /// Create capabilities with no access (must explicitly enable)
50    #[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    /// Create read-only capabilities
64    #[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    /// Create full access capabilities
78    #[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    /// Builder: enable read access
92    #[must_use]
93    pub const fn with_read(mut self, enabled: bool) -> Self {
94        self.read = enabled;
95        self
96    }
97
98    /// Builder: enable write access
99    #[must_use]
100    pub const fn with_write(mut self, enabled: bool) -> Self {
101        self.write = enabled;
102        self
103    }
104
105    /// Builder: enable exec access
106    #[must_use]
107    pub const fn with_exec(mut self, enabled: bool) -> Self {
108        self.exec = enabled;
109        self
110    }
111
112    /// Builder: set allowed paths
113    #[must_use]
114    pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
115        self.allowed_paths = paths;
116        self
117    }
118
119    /// Builder: set denied paths
120    #[must_use]
121    pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
122        self.denied_paths = paths;
123        self
124    }
125
126    /// Builder: set allowed commands
127    #[must_use]
128    pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
129        self.allowed_commands = commands;
130        self
131    }
132
133    /// Builder: set denied commands
134    #[must_use]
135    pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
136        self.denied_commands = commands;
137        self
138    }
139
140    /// Check read permission, returning the denial reason on failure.
141    ///
142    /// # Errors
143    ///
144    /// Returns the denial reason when read is disabled, the path matches a
145    /// denied pattern, or the path is not in the allowed list.
146    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    /// Check write permission, returning the denial reason on failure.
154    ///
155    /// # Errors
156    ///
157    /// Returns the denial reason when write is disabled, the path matches a
158    /// denied pattern, or the path is not in the allowed list.
159    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    /// Check exec permission, returning the denial reason on failure.
167    ///
168    /// # Errors
169    ///
170    /// Returns the denial reason when exec is disabled, the command matches a
171    /// denied pattern, or the command is not in the allowed list.
172    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    /// Returns `true` if reading `path` is allowed.
180    #[must_use]
181    pub fn can_read(&self, path: &str) -> bool {
182        self.check_read(path).is_ok()
183    }
184
185    /// Returns `true` if writing `path` is allowed.
186    #[must_use]
187    pub fn can_write(&self, path: &str) -> bool {
188        self.check_write(path).is_ok()
189    }
190
191    /// Returns `true` if executing `command` is allowed.
192    #[must_use]
193    pub fn can_exec(&self, command: &str) -> bool {
194        self.check_exec(command).is_ok()
195    }
196
197    /// Check whether a path passes the allow/deny rules, returning
198    /// the specific denial reason on failure.
199    ///
200    /// # Errors
201    ///
202    /// Returns the denial reason when the path matches a denied pattern
203    /// or is not in the allowed list.
204    pub fn check_path(&self, path: &str) -> Result<(), String> {
205        // Denied patterns take precedence
206        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 allowed_paths is empty, all non-denied paths are allowed
213        if self.allowed_paths.is_empty() {
214            return Ok(());
215        }
216
217        // Check if path matches any allowed pattern
218        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    /// Check whether a command passes the allow/deny rules, returning
231    /// the specific denial reason on failure.
232    ///
233    /// # Errors
234    ///
235    /// Returns the denial reason when the command matches a denied pattern
236    /// or is not in the allowed list.
237    pub fn check_command(&self, command: &str) -> Result<(), String> {
238        // Denied patterns take precedence
239        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 allowed_commands is empty, all non-denied commands are allowed
246        if self.allowed_commands.is_empty() {
247            return Ok(());
248        }
249
250        // Check if command matches any allowed pattern
251        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
264/// Simple glob matching (supports * and ** wildcards)
265fn glob_match(pattern: &str, path: &str) -> bool {
266    // Handle special case: pattern is just **
267    if pattern == "**" {
268        return true; // Matches everything
269    }
270
271    // Escape regex special characters except * and ?
272    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    // Handle glob patterns:
284    // - **/ at start or middle: zero or more path components (including leading /)
285    // - /** at end: matches everything after
286    // - * : matches any characters except /
287    let pattern = escaped
288        .replace("**/", "\x00") // **/ -> placeholder
289        .replace("/**", "\x01") // /** -> placeholder
290        .replace('*', "[^/]*") // * -> match non-slash characters
291        .replace('\x00', "(.*/)?") // **/ as optional prefix (handles absolute paths)
292        .replace('\x01', "(/.*)?"); // /** as optional suffix
293
294    let regex = format!("^{pattern}$");
295    regex_match(&regex, path)
296}
297
298/// Simple regex matching (returns false on invalid patterns)
299fn 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        // Default is permissive — no paths or commands are denied
314        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        // Denied paths (relative)
348        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        // Denied paths (absolute — after resolve_path)
354        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        // Normal files still allowed
360        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        // Common shell patterns are NOT blocked
396        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        // Simple wildcards
419        assert!(glob_match("*.rs", "main.rs"));
420        assert!(!glob_match("*.rs", "src/main.rs"));
421
422        // Double star for recursive matching in subdirectories
423        assert!(glob_match("**/*.rs", "src/main.rs"));
424        assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
425
426        // Directory patterns with /** suffix
427        assert!(glob_match("src/**", "src/lib/utils.rs"));
428        assert!(glob_match("src/**", "src/main.rs"));
429
430        // Match files in any subdirectory
431        assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
432        assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
433
434        // Root-level matches need direct pattern
435        assert!(glob_match("test*", "test_main.rs"));
436        assert!(glob_match("test*.rs", "test_main.rs"));
437
438        // Absolute paths (tools resolve to absolute before checking capabilities)
439        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        // Absolute paths should NOT false-positive
448        assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
449        assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
450    }
451
452    // =============================================
453    // Diagnostic reason tests (check_* methods)
454    // =============================================
455
456    #[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    /// Verify `full_access()` never blocks common shell patterns that agents
519    /// routinely emit. Each entry here was either denied in a real session
520    /// or represents a pattern class that naive deny-lists would break.
521    #[test]
522    fn full_access_allows_common_shell_patterns() {
523        let caps = AgentCapabilities::full_access();
524
525        let commands = [
526            // Heredoc with cat redirect (denied in previous session)
527            "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
528            // Grep with pipe-separated OR patterns (denied in previous session)
529            r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
530            // Multi-command chains
531            "cd /workspace && cargo build && cargo test",
532            "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
533            // Pipes and redirects
534            "cargo test 2>&1 | head -50",
535            "cat file.txt | grep pattern | wc -l",
536            "echo 'data' >> /tmp/append.txt",
537            // Subshells and grouping
538            "(cd /tmp && ls -la)",
539            "{ echo a; echo b; } > /tmp/out.txt",
540            // Process substitution and special chars
541            "diff <(sort file1) <(sort file2)",
542            "find . -name '*.rs' -exec grep -l 'TODO' {} +",
543            // Common dev commands
544            "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            // Commands with special regex chars that shouldn't trip up matching
550            "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    /// Verify `full_access()` allows reading/writing any path, including
564    /// paths that a naive deny-list might block (dotfiles, tmp, etc.).
565    #[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    /// Verify `Default` is `full_access()` — the SDK is unopinionated out of the box.
594    /// Consumers restrict from there, not opt-in to each capability.
595    #[test]
596    fn default_is_full_access() {
597        let caps = AgentCapabilities::default();
598
599        // Everything allowed by default
600        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        // No deny lists
605        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}