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).is_ok_and(|re| re.is_match(text))
313}
314
315/// Regex matching for deny rules — fails CLOSED on invalid patterns.
316/// An invalid deny pattern blocks everything to prevent misconfigured
317/// deny rules from silently allowing dangerous commands.
318fn 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        // Default is permissive — no paths or commands are denied
331        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        // Denied paths (relative)
365        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        // Denied paths (absolute — after resolve_path)
371        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        // Normal files still allowed
377        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        // Common shell patterns are NOT blocked
413        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        // Simple wildcards
436        assert!(glob_match("*.rs", "main.rs"));
437        assert!(!glob_match("*.rs", "src/main.rs"));
438
439        // Double star for recursive matching in subdirectories
440        assert!(glob_match("**/*.rs", "src/main.rs"));
441        assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
442
443        // Directory patterns with /** suffix
444        assert!(glob_match("src/**", "src/lib/utils.rs"));
445        assert!(glob_match("src/**", "src/main.rs"));
446
447        // Match files in any subdirectory
448        assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
449        assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
450
451        // Root-level matches need direct pattern
452        assert!(glob_match("test*", "test_main.rs"));
453        assert!(glob_match("test*.rs", "test_main.rs"));
454
455        // Absolute paths (tools resolve to absolute before checking capabilities)
456        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        // Absolute paths should NOT false-positive
465        assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
466        assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
467    }
468
469    // =============================================
470    // Diagnostic reason tests (check_* methods)
471    // =============================================
472
473    #[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    /// Verify `full_access()` never blocks common shell patterns that agents
536    /// routinely emit. Each entry here was either denied in a real session
537    /// or represents a pattern class that naive deny-lists would break.
538    #[test]
539    fn full_access_allows_common_shell_patterns() {
540        let caps = AgentCapabilities::full_access();
541
542        let commands = [
543            // Heredoc with cat redirect (denied in previous session)
544            "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
545            // Grep with pipe-separated OR patterns (denied in previous session)
546            r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
547            // Multi-command chains
548            "cd /workspace && cargo build && cargo test",
549            "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
550            // Pipes and redirects
551            "cargo test 2>&1 | head -50",
552            "cat file.txt | grep pattern | wc -l",
553            "echo 'data' >> /tmp/append.txt",
554            // Subshells and grouping
555            "(cd /tmp && ls -la)",
556            "{ echo a; echo b; } > /tmp/out.txt",
557            // Process substitution and special chars
558            "diff <(sort file1) <(sort file2)",
559            "find . -name '*.rs' -exec grep -l 'TODO' {} +",
560            // Common dev commands
561            "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            // Commands with special regex chars that shouldn't trip up matching
567            "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    /// Verify `full_access()` allows reading/writing any path, including
581    /// paths that a naive deny-list might block (dotfiles, tmp, etc.).
582    #[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        // An invalid regex in denied_commands should block everything (fail closed)
613        let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
614
615        // The invalid pattern should cause all commands to be denied
616        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        // An invalid regex in allowed_commands should not grant access (fail open)
623        let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
624
625        // The invalid pattern should not match, so nothing is allowed
626        assert!(caps.check_command("cargo build").is_err());
627    }
628
629    /// Verify `Default` is `full_access()` — the SDK is unopinionated out of the box.
630    /// Consumers restrict from there, not opt-in to each capability.
631    #[test]
632    fn default_is_full_access() {
633        let caps = AgentCapabilities::default();
634
635        // Everything allowed by default
636        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        // No deny lists
641        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}