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    /// # Security Note
234    ///
235    /// Regex-based command filtering is a heuristic, not a security boundary.
236    /// Shell metacharacters (`;`, `&&`, `|`, backticks, `$()`) allow chaining
237    /// arbitrary commands. For example, `denied_commands: ["^sudo"]` does NOT
238    /// block `bash -c "sudo rm -rf /"`. The `pre_tool_use` hook is the
239    /// authoritative gate for command approval.
240    ///
241    /// Invalid deny patterns fail closed (block everything) to prevent
242    /// misconfigured deny rules from silently allowing dangerous commands.
243    ///
244    /// # Errors
245    ///
246    /// Returns the denial reason when the command matches a denied pattern
247    /// or is not in the allowed list.
248    pub fn check_command(&self, command: &str) -> Result<(), String> {
249        // Denied patterns take precedence. Invalid patterns fail CLOSED.
250        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 allowed_commands is empty, all non-denied commands are allowed
257        if self.allowed_commands.is_empty() {
258            return Ok(());
259        }
260
261        // Check if command matches any allowed pattern
262        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
275/// Simple glob matching (supports * and ** wildcards)
276fn glob_match(pattern: &str, path: &str) -> bool {
277    // Handle special case: pattern is just **
278    if pattern == "**" {
279        return true; // Matches everything
280    }
281
282    // Escape regex special characters except * and ?
283    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    // Handle glob patterns:
295    // - **/ at start or middle: zero or more path components (including leading /)
296    // - /** at end: matches everything after
297    // - * : matches any characters except /
298    let pattern = escaped
299        .replace("**/", "\x00") // **/ -> placeholder
300        .replace("/**", "\x01") // /** -> placeholder
301        .replace('*', "[^/]*") // * -> match non-slash characters
302        .replace('\x00', "(.*/)?") // **/ as optional prefix (handles absolute paths)
303        .replace('\x01', "(/.*)?"); // /** as optional suffix
304
305    let regex = format!("^{pattern}$");
306    regex_match(&regex, path)
307}
308
309/// Simple regex matching (returns false on invalid patterns).
310/// Used for allow rules — an invalid allow pattern should not grant access.
311fn 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
317/// Regex matching for deny rules — fails CLOSED on invalid patterns.
318/// An invalid deny pattern blocks everything to prevent misconfigured
319/// deny rules from silently allowing dangerous commands.
320fn regex_match_deny(pattern: &str, text: &str) -> bool {
321    regex::Regex::new(pattern)
322        .map(|re| re.is_match(text))
323        .unwrap_or(true) // Invalid pattern = deny (fail closed)
324}
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        // Default is permissive — no paths or commands are denied
335        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        // Denied paths (relative)
369        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        // Denied paths (absolute — after resolve_path)
375        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        // Normal files still allowed
381        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        // Common shell patterns are NOT blocked
417        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        // Simple wildcards
440        assert!(glob_match("*.rs", "main.rs"));
441        assert!(!glob_match("*.rs", "src/main.rs"));
442
443        // Double star for recursive matching in subdirectories
444        assert!(glob_match("**/*.rs", "src/main.rs"));
445        assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
446
447        // Directory patterns with /** suffix
448        assert!(glob_match("src/**", "src/lib/utils.rs"));
449        assert!(glob_match("src/**", "src/main.rs"));
450
451        // Match files in any subdirectory
452        assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
453        assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
454
455        // Root-level matches need direct pattern
456        assert!(glob_match("test*", "test_main.rs"));
457        assert!(glob_match("test*.rs", "test_main.rs"));
458
459        // Absolute paths (tools resolve to absolute before checking capabilities)
460        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        // Absolute paths should NOT false-positive
469        assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
470        assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
471    }
472
473    // =============================================
474    // Diagnostic reason tests (check_* methods)
475    // =============================================
476
477    #[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    /// Verify `full_access()` never blocks common shell patterns that agents
540    /// routinely emit. Each entry here was either denied in a real session
541    /// or represents a pattern class that naive deny-lists would break.
542    #[test]
543    fn full_access_allows_common_shell_patterns() {
544        let caps = AgentCapabilities::full_access();
545
546        let commands = [
547            // Heredoc with cat redirect (denied in previous session)
548            "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
549            // Grep with pipe-separated OR patterns (denied in previous session)
550            r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
551            // Multi-command chains
552            "cd /workspace && cargo build && cargo test",
553            "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
554            // Pipes and redirects
555            "cargo test 2>&1 | head -50",
556            "cat file.txt | grep pattern | wc -l",
557            "echo 'data' >> /tmp/append.txt",
558            // Subshells and grouping
559            "(cd /tmp && ls -la)",
560            "{ echo a; echo b; } > /tmp/out.txt",
561            // Process substitution and special chars
562            "diff <(sort file1) <(sort file2)",
563            "find . -name '*.rs' -exec grep -l 'TODO' {} +",
564            // Common dev commands
565            "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            // Commands with special regex chars that shouldn't trip up matching
571            "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    /// Verify `full_access()` allows reading/writing any path, including
585    /// paths that a naive deny-list might block (dotfiles, tmp, etc.).
586    #[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        // An invalid regex in denied_commands should block everything (fail closed)
617        let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
618
619        // The invalid pattern should cause all commands to be denied
620        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        // An invalid regex in allowed_commands should not grant access (fail open)
627        let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
628
629        // The invalid pattern should not match, so nothing is allowed
630        assert!(caps.check_command("cargo build").is_err());
631    }
632
633    /// Verify `Default` is `full_access()` — the SDK is unopinionated out of the box.
634    /// Consumers restrict from there, not opt-in to each capability.
635    #[test]
636    fn default_is_full_access() {
637        let caps = AgentCapabilities::default();
638
639        // Everything allowed by default
640        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        // No deny lists
645        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}