Skip to main content

agent_sdk/
capabilities.rs

1use serde::{Deserialize, Serialize};
2use std::sync::OnceLock;
3
4/// Capabilities that control what the agent can do.
5///
6/// This provides a security model for primitive tools (Read, Write, Grep, Glob, Bash).
7/// Paths are matched using glob patterns, commands using regex patterns.
8///
9/// By default, everything is allowed — the SDK is unopinionated and leaves
10/// security policy to the client. Use the builder methods to configure restrictions.
11///
12/// # Path matching
13///
14/// Primitive tools resolve the model-supplied path to an **absolute** path
15/// (against the environment root) *before* consulting these rules, so path
16/// patterns are matched against that resolved absolute path — not the raw
17/// relative input. Prefer `**/`-prefixed patterns (e.g. `**/src/**`) or
18/// absolute patterns over bare relative ones like `src/**`, which never match
19/// the absolute paths the tools produce.
20///
21/// # Example
22///
23/// ```rust
24/// use agent_sdk::AgentCapabilities;
25///
26/// // Read-only agent restricted to the `src/` tree. Because tools check the
27/// // resolved absolute path, use a `**/`-prefixed pattern rather than `src/**`.
28/// let caps = AgentCapabilities::read_only()
29///     .with_allowed_paths(vec!["**/src/**".into()]);
30/// assert!(caps.can_read("/workspace/src/main.rs"));
31///
32/// // Full access agent with some restrictions
33/// let caps = AgentCapabilities::full_access()
34///     .with_denied_paths(vec!["**/.env*".into(), "**/secrets/**".into()]);
35/// ```
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct AgentCapabilities {
38    /// Can read files
39    pub read: bool,
40    /// Can write/edit files
41    pub write: bool,
42    /// Can execute shell commands
43    pub exec: bool,
44    /// Allowed path patterns (glob). Empty means all paths allowed.
45    ///
46    /// Matched against the resolved **absolute** path produced by primitive
47    /// tools — see the type-level "Path matching" note.
48    pub allowed_paths: Vec<String>,
49    /// Denied path patterns (glob). Takes precedence over `allowed_paths`.
50    ///
51    /// Matched against the resolved **absolute** path produced by primitive
52    /// tools — see the type-level "Path matching" note.
53    pub denied_paths: Vec<String>,
54    /// Allowed commands (regex patterns). Empty means all commands allowed when `exec=true`.
55    pub allowed_commands: Vec<String>,
56    /// Denied commands (regex patterns). Takes precedence over `allowed_commands`.
57    pub denied_commands: Vec<String>,
58    /// Lazily-compiled patterns, cached so per-result hot paths (grep/glob call
59    /// `check_read` once per match) do not recompile regexes on every check.
60    ///
61    /// Populated on first use from the pattern lists above and reset by the
62    /// `with_*` builder methods. Mutating the public pattern `Vec`s directly
63    /// (instead of via the builders) will not refresh this cache.
64    #[serde(skip)]
65    compiled: OnceLock<CompiledPatterns>,
66}
67
68/// Pre-compiled forms of the pattern lists, built once per configuration.
69///
70/// Each entry is `None` when its source pattern failed to compile; matching
71/// then falls back to the pattern list's documented fail-open (allow) or
72/// fail-closed (deny) behaviour.
73#[derive(Clone, Debug, Default)]
74struct CompiledPatterns {
75    allowed_paths: Vec<Option<regex::Regex>>,
76    denied_paths: Vec<Option<regex::Regex>>,
77    allowed_commands: Vec<Option<regex::Regex>>,
78    denied_commands: Vec<Option<regex::Regex>>,
79}
80
81impl Default for AgentCapabilities {
82    fn default() -> Self {
83        Self::full_access()
84    }
85}
86
87impl AgentCapabilities {
88    /// Create capabilities with no access (must explicitly enable)
89    #[must_use]
90    pub const fn none() -> Self {
91        Self {
92            read: false,
93            write: false,
94            exec: false,
95            allowed_paths: vec![],
96            denied_paths: vec![],
97            allowed_commands: vec![],
98            denied_commands: vec![],
99            compiled: OnceLock::new(),
100        }
101    }
102
103    /// Create read-only capabilities
104    #[must_use]
105    pub const fn read_only() -> Self {
106        Self {
107            read: true,
108            write: false,
109            exec: false,
110            allowed_paths: vec![],
111            denied_paths: vec![],
112            allowed_commands: vec![],
113            denied_commands: vec![],
114            compiled: OnceLock::new(),
115        }
116    }
117
118    /// Create full access capabilities
119    #[must_use]
120    pub const fn full_access() -> Self {
121        Self {
122            read: true,
123            write: true,
124            exec: true,
125            allowed_paths: vec![],
126            denied_paths: vec![],
127            allowed_commands: vec![],
128            denied_commands: vec![],
129            compiled: OnceLock::new(),
130        }
131    }
132
133    /// Builder: enable read access
134    #[must_use]
135    pub const fn with_read(mut self, enabled: bool) -> Self {
136        self.read = enabled;
137        self
138    }
139
140    /// Builder: enable write access
141    #[must_use]
142    pub const fn with_write(mut self, enabled: bool) -> Self {
143        self.write = enabled;
144        self
145    }
146
147    /// Builder: enable exec access
148    #[must_use]
149    pub const fn with_exec(mut self, enabled: bool) -> Self {
150        self.exec = enabled;
151        self
152    }
153
154    /// Builder: set allowed paths
155    #[must_use]
156    pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
157        self.allowed_paths = paths;
158        self.compiled = OnceLock::new();
159        self
160    }
161
162    /// Builder: set denied paths
163    #[must_use]
164    pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
165        self.denied_paths = paths;
166        self.compiled = OnceLock::new();
167        self
168    }
169
170    /// Builder: set allowed commands
171    #[must_use]
172    pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
173        self.allowed_commands = commands;
174        self.compiled = OnceLock::new();
175        self
176    }
177
178    /// Builder: set denied commands
179    #[must_use]
180    pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
181        self.denied_commands = commands;
182        self.compiled = OnceLock::new();
183        self
184    }
185
186    /// Check read permission, returning the denial reason on failure.
187    ///
188    /// # Errors
189    ///
190    /// Returns the denial reason when read is disabled, the path matches a
191    /// denied pattern, or the path is not in the allowed list.
192    pub fn check_read(&self, path: &str) -> Result<(), String> {
193        if !self.read {
194            return Err("read access is disabled".into());
195        }
196        self.check_path(path)
197    }
198
199    /// Check write permission, returning the denial reason on failure.
200    ///
201    /// # Errors
202    ///
203    /// Returns the denial reason when write is disabled, the path matches a
204    /// denied pattern, or the path is not in the allowed list.
205    pub fn check_write(&self, path: &str) -> Result<(), String> {
206        if !self.write {
207            return Err("write access is disabled".into());
208        }
209        self.check_path(path)
210    }
211
212    /// Check exec permission, returning the denial reason on failure.
213    ///
214    /// # Errors
215    ///
216    /// Returns the denial reason when exec is disabled, the command matches a
217    /// denied pattern, or the command is not in the allowed list.
218    pub fn check_exec(&self, command: &str) -> Result<(), String> {
219        if !self.exec {
220            return Err("command execution is disabled".into());
221        }
222        self.check_command(command)
223    }
224
225    /// Returns `true` if reading `path` is allowed.
226    #[must_use]
227    pub fn can_read(&self, path: &str) -> bool {
228        self.check_read(path).is_ok()
229    }
230
231    /// Returns `true` if writing `path` is allowed.
232    #[must_use]
233    pub fn can_write(&self, path: &str) -> bool {
234        self.check_write(path).is_ok()
235    }
236
237    /// Returns `true` if executing `command` is allowed.
238    #[must_use]
239    pub fn can_exec(&self, command: &str) -> bool {
240        self.check_exec(command).is_ok()
241    }
242
243    /// Check whether a path passes the allow/deny rules, returning
244    /// the specific denial reason on failure.
245    ///
246    /// # Errors
247    ///
248    /// Returns the denial reason when the path matches a denied pattern
249    /// or is not in the allowed list.
250    pub fn check_path(&self, path: &str) -> Result<(), String> {
251        let compiled = self.compiled();
252
253        // Denied patterns take precedence. Glob denies fail OPEN: a pattern
254        // that did not compile (`None`) simply does not match.
255        for (pattern, regex) in self.denied_paths.iter().zip(&compiled.denied_paths) {
256            if regex.as_ref().is_some_and(|re| re.is_match(path)) {
257                return Err(format!("path matches denied pattern '{pattern}'"));
258            }
259        }
260
261        // If allowed_paths is empty, all non-denied paths are allowed
262        if self.allowed_paths.is_empty() {
263            return Ok(());
264        }
265
266        // Check if path matches any allowed pattern
267        for regex in &compiled.allowed_paths {
268            if regex.as_ref().is_some_and(|re| re.is_match(path)) {
269                return Ok(());
270            }
271        }
272
273        Err(format!(
274            "path not in allowed list (allowed: [{}])",
275            self.allowed_paths.join(", ")
276        ))
277    }
278
279    /// Check whether a command passes the allow/deny rules, returning
280    /// the specific denial reason on failure.
281    ///
282    /// # Security Note
283    ///
284    /// Regex-based command filtering is a heuristic, not a security boundary.
285    /// Shell metacharacters (`;`, `&&`, `|`, backticks, `$()`) allow chaining
286    /// arbitrary commands. For example, `denied_commands: ["^sudo"]` does NOT
287    /// block `bash -c "sudo rm -rf /"`. The `pre_tool_use` hook is the
288    /// authoritative gate for command approval.
289    ///
290    /// Invalid deny patterns fail closed (block everything) to prevent
291    /// misconfigured deny rules from silently allowing dangerous commands.
292    ///
293    /// # Errors
294    ///
295    /// Returns the denial reason when the command matches a denied pattern
296    /// or is not in the allowed list.
297    pub fn check_command(&self, command: &str) -> Result<(), String> {
298        let compiled = self.compiled();
299
300        // Denied patterns take precedence. Deny rules fail CLOSED: a pattern
301        // that did not compile (`None`) blocks everything to prevent a
302        // misconfigured deny rule from silently allowing dangerous commands.
303        for (pattern, regex) in self.denied_commands.iter().zip(&compiled.denied_commands) {
304            if regex.as_ref().is_none_or(|re| re.is_match(command)) {
305                return Err(format!("command matches denied pattern '{pattern}'"));
306            }
307        }
308
309        // If allowed_commands is empty, all non-denied commands are allowed
310        if self.allowed_commands.is_empty() {
311            return Ok(());
312        }
313
314        // Check if command matches any allowed pattern. Allow rules fail OPEN:
315        // an invalid pattern (`None`) does not grant access.
316        for regex in &compiled.allowed_commands {
317            if regex.as_ref().is_some_and(|re| re.is_match(command)) {
318                return Ok(());
319            }
320        }
321
322        Err(format!(
323            "command not in allowed list (allowed: [{}])",
324            self.allowed_commands.join(", ")
325        ))
326    }
327
328    /// Return the lazily-compiled patterns, building them once on first use.
329    fn compiled(&self) -> &CompiledPatterns {
330        self.compiled.get_or_init(|| CompiledPatterns {
331            allowed_paths: self
332                .allowed_paths
333                .iter()
334                .map(|p| compile_glob(p.as_str()))
335                .collect(),
336            denied_paths: self
337                .denied_paths
338                .iter()
339                .map(|p| compile_glob(p.as_str()))
340                .collect(),
341            allowed_commands: self
342                .allowed_commands
343                .iter()
344                .map(|p| regex::Regex::new(p.as_str()).ok())
345                .collect(),
346            denied_commands: self
347                .denied_commands
348                .iter()
349                .map(|p| regex::Regex::new(p.as_str()).ok())
350                .collect(),
351        })
352    }
353}
354
355/// Convert a glob pattern (supporting `*` and `**` wildcards) into an anchored
356/// regex string.
357///
358/// - `**/` (prefix/middle): zero or more leading path components.
359/// - `/**` (suffix): everything below a directory.
360/// - `*`: any run of non-`/` characters.
361fn glob_to_regex(pattern: &str) -> String {
362    // Special case: a bare `**` matches everything (including across `/`).
363    if pattern == "**" {
364        return "^.*$".to_string();
365    }
366
367    // Escape regex special characters except the glob wildcards `*` and `?`.
368    let mut escaped = String::with_capacity(pattern.len());
369    for c in pattern.chars() {
370        match c {
371            '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
372                escaped.push('\\');
373                escaped.push(c);
374            }
375            _ => escaped.push(c),
376        }
377    }
378
379    let body = escaped
380        .replace("**/", "\x00") // **/ -> placeholder
381        .replace("/**", "\x01") // /** -> placeholder
382        .replace('*', "[^/]*") // * -> match non-slash characters
383        .replace('\x00', "(.*/)?") // **/ as optional prefix (handles absolute paths)
384        .replace('\x01', "(/.*)?"); // /** as optional suffix
385
386    format!("^{body}$")
387}
388
389/// Compile a glob pattern to a regex, returning `None` if it does not compile.
390fn compile_glob(pattern: &str) -> Option<regex::Regex> {
391    regex::Regex::new(&glob_to_regex(pattern)).ok()
392}
393
394/// Simple glob matching (supports `*` and `**` wildcards).
395///
396/// Returns `false` on patterns that fail to compile (fail open). Used by the
397/// unit tests; production checks go through the cached [`AgentCapabilities`].
398#[cfg(test)]
399fn glob_match(pattern: &str, path: &str) -> bool {
400    compile_glob(pattern).is_some_and(|re| re.is_match(path))
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn test_default_has_no_deny_lists() {
409        let caps = AgentCapabilities::default();
410
411        // Default is permissive — no paths or commands are denied
412        assert!(caps.check_path("src/main.rs").is_ok());
413        assert!(caps.check_path(".env").is_ok());
414        assert!(caps.check_path("/workspace/secrets/key.txt").is_ok());
415        assert!(caps.check_command("any command").is_ok());
416    }
417
418    #[test]
419    fn test_full_access_allows_everything() {
420        let caps = AgentCapabilities::full_access();
421
422        assert!(caps.check_read("/any/path").is_ok());
423        assert!(caps.check_write("/any/path").is_ok());
424        assert!(caps.check_exec("any command").is_ok());
425    }
426
427    #[test]
428    fn test_read_only_cannot_write() {
429        let caps = AgentCapabilities::read_only();
430
431        assert!(caps.check_read("src/main.rs").is_ok());
432        assert!(caps.check_write("src/main.rs").is_err());
433        assert!(caps.check_exec("ls").is_err());
434    }
435
436    #[test]
437    fn test_client_configured_denied_paths() {
438        let caps = AgentCapabilities::full_access().with_denied_paths(vec![
439            "**/.env".into(),
440            "**/.env.*".into(),
441            "**/secrets/**".into(),
442            "**/*.pem".into(),
443        ]);
444
445        // Denied paths (relative)
446        assert!(caps.check_path(".env").is_err());
447        assert!(caps.check_path("config/.env.local").is_err());
448        assert!(caps.check_path("app/secrets/key.txt").is_err());
449        assert!(caps.check_path("certs/server.pem").is_err());
450
451        // Denied paths (absolute — after resolve_path)
452        assert!(caps.check_path("/workspace/.env").is_err());
453        assert!(caps.check_path("/workspace/.env.production").is_err());
454        assert!(caps.check_path("/workspace/secrets/key.txt").is_err());
455        assert!(caps.check_path("/workspace/certs/server.pem").is_err());
456
457        // Normal files still allowed
458        assert!(caps.check_path("src/main.rs").is_ok());
459        assert!(caps.check_path("/workspace/src/main.rs").is_ok());
460        assert!(caps.check_path("/workspace/README.md").is_ok());
461    }
462
463    #[test]
464    fn test_allowed_paths_restriction() {
465        let caps = AgentCapabilities::read_only()
466            .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
467
468        assert!(caps.check_path("src/main.rs").is_ok());
469        assert!(caps.check_path("src/lib/utils.rs").is_ok());
470        assert!(caps.check_path("tests/integration.rs").is_ok());
471        assert!(caps.check_path("config/settings.toml").is_err());
472        assert!(caps.check_path("README.md").is_err());
473    }
474
475    #[test]
476    fn allowed_paths_match_resolved_absolute_paths() {
477        // Primitive tools resolve inputs to absolute paths before checking, so
478        // a `**/`-prefixed pattern must permit the absolute paths they produce.
479        let caps = AgentCapabilities::read_only().with_allowed_paths(vec!["**/src/**".into()]);
480
481        assert!(caps.check_read("/workspace/src/main.rs").is_ok());
482        assert!(caps.check_read("/workspace/src/lib/mod.rs").is_ok());
483        assert!(caps.can_read("/Users/dev/project/src/deep/nested.rs"));
484
485        // Paths outside the allowed tree are still denied.
486        assert!(caps.check_read("/workspace/README.md").is_err());
487        assert!(caps.check_read("/workspace/tests/it.rs").is_err());
488    }
489
490    #[test]
491    fn rebuilding_patterns_invalidates_compiled_cache() {
492        // First check populates the compiled-pattern cache for the empty list.
493        let caps = AgentCapabilities::full_access();
494        assert!(caps.check_path("/workspace/.env").is_ok());
495
496        // A capability rebuilt via a `with_*` builder must honor the new deny
497        // list — i.e. the builder resets the cached compiled patterns.
498        let restricted = caps.with_denied_paths(vec!["**/.env".into()]);
499        assert!(restricted.check_path("/workspace/.env").is_err());
500        assert!(restricted.check_path("/workspace/src/main.rs").is_ok());
501        // Repeated checks (which reuse the cache) stay consistent.
502        assert!(restricted.check_path("/workspace/.env").is_err());
503    }
504
505    #[test]
506    fn test_denied_takes_precedence() {
507        let caps = AgentCapabilities::read_only()
508            .with_denied_paths(vec!["**/secret/**".into()])
509            .with_allowed_paths(vec!["**".into()]);
510
511        assert!(caps.check_path("src/main.rs").is_ok());
512        assert!(caps.check_path("src/secret/key.txt").is_err());
513    }
514
515    #[test]
516    fn test_client_configured_denied_commands() {
517        let caps = AgentCapabilities::full_access()
518            .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
519
520        assert!(caps.check_command("rm -rf /").is_err());
521        assert!(caps.check_command("sudo rm file").is_err());
522
523        // Common shell patterns are NOT blocked
524        assert!(caps.check_command("ls -la").is_ok());
525        assert!(caps.check_command("cargo build").is_ok());
526        assert!(caps.check_command("unzip file.zip 2>/dev/null").is_ok());
527        assert!(
528            caps.check_command("python3 -m markitdown file.pptx")
529                .is_ok()
530        );
531    }
532
533    #[test]
534    fn test_allowed_commands_restriction() {
535        let caps = AgentCapabilities::full_access()
536            .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
537
538        assert!(caps.check_command("cargo build").is_ok());
539        assert!(caps.check_command("git status").is_ok());
540        assert!(caps.check_command("ls -la").is_err());
541        assert!(caps.check_command("npm install").is_err());
542    }
543
544    #[test]
545    fn test_glob_matching() {
546        // Simple wildcards
547        assert!(glob_match("*.rs", "main.rs"));
548        assert!(!glob_match("*.rs", "src/main.rs"));
549
550        // Double star for recursive matching in subdirectories
551        assert!(glob_match("**/*.rs", "src/main.rs"));
552        assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
553
554        // Directory patterns with /** suffix
555        assert!(glob_match("src/**", "src/lib/utils.rs"));
556        assert!(glob_match("src/**", "src/main.rs"));
557
558        // Match files in any subdirectory
559        assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
560        assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
561
562        // Root-level matches need direct pattern
563        assert!(glob_match("test*", "test_main.rs"));
564        assert!(glob_match("test*.rs", "test_main.rs"));
565
566        // Absolute paths (tools resolve to absolute before checking capabilities)
567        assert!(glob_match("**/.env", "/workspace/.env"));
568        assert!(glob_match("**/.env.*", "/workspace/.env.local"));
569        assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
570        assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
571        assert!(glob_match("**/*.key", "/workspace/server.key"));
572        assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
573        assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
574
575        // Absolute paths should NOT false-positive
576        assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
577        assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
578    }
579
580    // =============================================
581    // Diagnostic reason tests (check_* methods)
582    // =============================================
583
584    #[test]
585    fn check_read_disabled_explains_reason() {
586        let caps = AgentCapabilities::none();
587        let err = caps.check_read("src/main.rs").unwrap_err();
588        assert!(err.contains("read access is disabled"), "got: {err}");
589    }
590
591    #[test]
592    fn check_write_disabled_explains_reason() {
593        let caps = AgentCapabilities::read_only();
594        let err = caps.check_write("src/main.rs").unwrap_err();
595        assert!(err.contains("write access is disabled"), "got: {err}");
596    }
597
598    #[test]
599    fn check_exec_disabled_explains_reason() {
600        let caps = AgentCapabilities::read_only();
601        let err = caps.check_exec("ls").unwrap_err();
602        assert!(err.contains("command execution is disabled"), "got: {err}");
603    }
604
605    #[test]
606    fn check_read_denied_path_explains_pattern() {
607        let caps = AgentCapabilities::full_access().with_denied_paths(vec!["**/.env*".into()]);
608        let err = caps.check_read("/workspace/.env.local").unwrap_err();
609        assert!(err.contains("denied pattern"), "got: {err}");
610        assert!(err.contains("**/.env*"), "got: {err}");
611    }
612
613    #[test]
614    fn check_read_not_in_allowed_list() {
615        let caps = AgentCapabilities::full_access().with_allowed_paths(vec!["src/**".into()]);
616        let err = caps.check_read("/workspace/README.md").unwrap_err();
617        assert!(err.contains("not in allowed list"), "got: {err}");
618        assert!(err.contains("src/**"), "got: {err}");
619    }
620
621    #[test]
622    fn check_exec_denied_command_explains_pattern() {
623        let caps = AgentCapabilities::full_access().with_denied_commands(vec![r"^sudo\s".into()]);
624        let err = caps.check_exec("sudo rm -rf /").unwrap_err();
625        assert!(err.contains("denied pattern"), "got: {err}");
626        assert!(err.contains("^sudo\\s"), "got: {err}");
627    }
628
629    #[test]
630    fn check_exec_not_in_allowed_list() {
631        let caps = AgentCapabilities::full_access()
632            .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
633        let err = caps.check_exec("npm install").unwrap_err();
634        assert!(err.contains("not in allowed list"), "got: {err}");
635        assert!(err.contains("^cargo "), "got: {err}");
636    }
637
638    #[test]
639    fn check_allowed_operations_return_ok() {
640        let caps = AgentCapabilities::full_access();
641        assert!(caps.check_read("any/path").is_ok());
642        assert!(caps.check_write("any/path").is_ok());
643        assert!(caps.check_exec("any command").is_ok());
644    }
645
646    /// Verify `full_access()` never blocks common shell patterns that agents
647    /// routinely emit. Each entry here was either denied in a real session
648    /// or represents a pattern class that naive deny-lists would break.
649    #[test]
650    fn full_access_allows_common_shell_patterns() {
651        let caps = AgentCapabilities::full_access();
652
653        let commands = [
654            // Heredoc with cat redirect (denied in previous session)
655            "cat > /tmp/test_caps.rs << 'EOF'\nfn main() { println!(\"hello\"); }\nEOF",
656            // Grep with pipe-separated OR patterns (denied in previous session)
657            r#"grep -n "agent_loop\|Permission\|permission\|denied\|Denied" src/agent_loop.rs"#,
658            // Multi-command chains
659            "cd /workspace && cargo build && cargo test",
660            "mkdir -p /tmp/test && cd /tmp/test && echo hello > file.txt",
661            // Pipes and redirects
662            "cargo test 2>&1 | head -50",
663            "cat file.txt | grep pattern | wc -l",
664            "echo 'data' >> /tmp/append.txt",
665            // Subshells and grouping
666            "(cd /tmp && ls -la)",
667            "{ echo a; echo b; } > /tmp/out.txt",
668            // Process substitution and special chars
669            "diff <(sort file1) <(sort file2)",
670            "find . -name '*.rs' -exec grep -l 'TODO' {} +",
671            // Common dev commands
672            "cargo clippy -- -D warnings",
673            "cargo fmt --check",
674            "git diff --stat HEAD~1",
675            "npm install && npm run build",
676            "python3 -c 'print(\"hello\")'",
677            // Commands with special regex chars that shouldn't trip up matching
678            "grep -rn 'foo(bar)' src/",
679            "echo '$HOME is ~/work'",
680            "ls *.rs",
681        ];
682
683        for cmd in &commands {
684            assert!(
685                caps.check_exec(cmd).is_ok(),
686                "full_access() unexpectedly blocked command: {cmd}"
687            );
688        }
689    }
690
691    /// Verify `full_access()` allows reading/writing any path, including
692    /// paths that a naive deny-list might block (dotfiles, tmp, etc.).
693    #[test]
694    fn full_access_allows_all_paths() {
695        let caps = AgentCapabilities::full_access();
696
697        let paths = [
698            "src/main.rs",
699            ".env",
700            ".env.local",
701            "/tmp/test_caps.rs",
702            "/home/user/.ssh/config",
703            "/workspace/secrets/api_key.txt",
704            "/workspace/certs/server.pem",
705            "Cargo.toml",
706            "node_modules/.package-lock.json",
707        ];
708
709        for path in &paths {
710            assert!(
711                caps.check_read(path).is_ok(),
712                "full_access() unexpectedly blocked read: {path}"
713            );
714            assert!(
715                caps.check_write(path).is_ok(),
716                "full_access() unexpectedly blocked write: {path}"
717            );
718        }
719    }
720
721    #[test]
722    fn invalid_deny_regex_fails_closed() {
723        // An invalid regex in denied_commands should block everything (fail closed)
724        let caps = AgentCapabilities::full_access().with_denied_commands(vec!["[unclosed".into()]);
725
726        // The invalid pattern should cause all commands to be denied
727        assert!(caps.check_command("cargo build").is_err());
728        assert!(caps.check_command("ls").is_err());
729    }
730
731    #[test]
732    fn invalid_allow_regex_fails_open() {
733        // An invalid regex in allowed_commands should not grant access (fail open)
734        let caps = AgentCapabilities::full_access().with_allowed_commands(vec!["[unclosed".into()]);
735
736        // The invalid pattern should not match, so nothing is allowed
737        assert!(caps.check_command("cargo build").is_err());
738    }
739
740    /// Verify `Default` is `full_access()` — the SDK is unopinionated out of the box.
741    /// Consumers restrict from there, not opt-in to each capability.
742    #[test]
743    fn default_is_full_access() {
744        let caps = AgentCapabilities::default();
745
746        // Everything allowed by default
747        assert!(caps.check_read("src/main.rs").is_ok());
748        assert!(caps.check_write("src/main.rs").is_ok());
749        assert!(caps.check_exec("ls").is_ok());
750
751        // No deny lists
752        assert!(caps.check_path(".env").is_ok());
753        assert!(caps.check_path("/home/user/.ssh/id_rsa").is_ok());
754        assert!(caps.check_command("sudo rm -rf /").is_ok());
755    }
756}