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, no paths or commands are denied — 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, Default, 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 AgentCapabilities {
43    /// Create capabilities with no access (must explicitly enable)
44    #[must_use]
45    pub const fn none() -> Self {
46        Self {
47            read: false,
48            write: false,
49            exec: false,
50            allowed_paths: vec![],
51            denied_paths: vec![],
52            allowed_commands: vec![],
53            denied_commands: vec![],
54        }
55    }
56
57    /// Create read-only capabilities
58    #[must_use]
59    pub const fn read_only() -> Self {
60        Self {
61            read: true,
62            write: false,
63            exec: false,
64            allowed_paths: vec![],
65            denied_paths: vec![],
66            allowed_commands: vec![],
67            denied_commands: vec![],
68        }
69    }
70
71    /// Create full access capabilities
72    #[must_use]
73    pub const fn full_access() -> Self {
74        Self {
75            read: true,
76            write: true,
77            exec: true,
78            allowed_paths: vec![],
79            denied_paths: vec![],
80            allowed_commands: vec![],
81            denied_commands: vec![],
82        }
83    }
84
85    /// Builder: enable read access
86    #[must_use]
87    pub const fn with_read(mut self, enabled: bool) -> Self {
88        self.read = enabled;
89        self
90    }
91
92    /// Builder: enable write access
93    #[must_use]
94    pub const fn with_write(mut self, enabled: bool) -> Self {
95        self.write = enabled;
96        self
97    }
98
99    /// Builder: enable exec access
100    #[must_use]
101    pub const fn with_exec(mut self, enabled: bool) -> Self {
102        self.exec = enabled;
103        self
104    }
105
106    /// Builder: set allowed paths
107    #[must_use]
108    pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
109        self.allowed_paths = paths;
110        self
111    }
112
113    /// Builder: set denied paths
114    #[must_use]
115    pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
116        self.denied_paths = paths;
117        self
118    }
119
120    /// Builder: set allowed commands
121    #[must_use]
122    pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
123        self.allowed_commands = commands;
124        self
125    }
126
127    /// Builder: set denied commands
128    #[must_use]
129    pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
130        self.denied_commands = commands;
131        self
132    }
133
134    /// Check if a path can be read
135    #[must_use]
136    pub fn can_read(&self, path: &str) -> bool {
137        self.read && self.path_allowed(path)
138    }
139
140    /// Check if a path can be written
141    #[must_use]
142    pub fn can_write(&self, path: &str) -> bool {
143        self.write && self.path_allowed(path)
144    }
145
146    /// Check if a command can be executed
147    #[must_use]
148    pub fn can_exec(&self, command: &str) -> bool {
149        self.exec && self.command_allowed(command)
150    }
151
152    /// Check if a path is allowed (not in denied list and in allowed list if specified)
153    #[must_use]
154    pub fn path_allowed(&self, path: &str) -> bool {
155        // Check denied patterns first (takes precedence)
156        for pattern in &self.denied_paths {
157            if glob_match(pattern, path) {
158                return false;
159            }
160        }
161
162        // If allowed_paths is empty, all non-denied paths are allowed
163        if self.allowed_paths.is_empty() {
164            return true;
165        }
166
167        // Check if path matches any allowed pattern
168        for pattern in &self.allowed_paths {
169            if glob_match(pattern, path) {
170                return true;
171            }
172        }
173
174        false
175    }
176
177    /// Check if a command is allowed
178    #[must_use]
179    pub fn command_allowed(&self, command: &str) -> bool {
180        // Check denied patterns first
181        for pattern in &self.denied_commands {
182            if regex_match(pattern, command) {
183                return false;
184            }
185        }
186
187        // If allowed_commands is empty, all non-denied commands are allowed
188        if self.allowed_commands.is_empty() {
189            return true;
190        }
191
192        // Check if command matches any allowed pattern
193        for pattern in &self.allowed_commands {
194            if regex_match(pattern, command) {
195                return true;
196            }
197        }
198
199        false
200    }
201}
202
203/// Simple glob matching (supports * and ** wildcards)
204fn glob_match(pattern: &str, path: &str) -> bool {
205    // Handle special case: pattern is just **
206    if pattern == "**" {
207        return true; // Matches everything
208    }
209
210    // Escape regex special characters except * and ?
211    let mut escaped = String::new();
212    for c in pattern.chars() {
213        match c {
214            '.' | '+' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' => {
215                escaped.push('\\');
216                escaped.push(c);
217            }
218            _ => escaped.push(c),
219        }
220    }
221
222    // Handle glob patterns:
223    // - **/ at start or middle: zero or more path components (including leading /)
224    // - /** at end: matches everything after
225    // - * : matches any characters except /
226    let pattern = escaped
227        .replace("**/", "\x00") // **/ -> placeholder
228        .replace("/**", "\x01") // /** -> placeholder
229        .replace('*', "[^/]*") // * -> match non-slash characters
230        .replace('\x00', "(.*/)?") // **/ as optional prefix (handles absolute paths)
231        .replace('\x01', "(/.*)?"); // /** as optional suffix
232
233    let regex = format!("^{pattern}$");
234    regex_match(&regex, path)
235}
236
237/// Simple regex matching (returns false on invalid patterns)
238fn regex_match(pattern: &str, text: &str) -> bool {
239    regex::Regex::new(pattern)
240        .map(|re| re.is_match(text))
241        .unwrap_or(false)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_default_has_no_deny_lists() {
250        let caps = AgentCapabilities::default();
251
252        // Default is permissive — no paths or commands are denied
253        assert!(caps.path_allowed("src/main.rs"));
254        assert!(caps.path_allowed(".env"));
255        assert!(caps.path_allowed("/workspace/secrets/key.txt"));
256        assert!(caps.command_allowed("any command"));
257    }
258
259    #[test]
260    fn test_full_access_allows_everything() {
261        let caps = AgentCapabilities::full_access();
262
263        assert!(caps.can_read("/any/path"));
264        assert!(caps.can_write("/any/path"));
265        assert!(caps.can_exec("any command"));
266    }
267
268    #[test]
269    fn test_read_only_cannot_write() {
270        let caps = AgentCapabilities::read_only();
271
272        assert!(caps.can_read("src/main.rs"));
273        assert!(!caps.can_write("src/main.rs"));
274        assert!(!caps.can_exec("ls"));
275    }
276
277    #[test]
278    fn test_client_configured_denied_paths() {
279        let caps = AgentCapabilities::full_access().with_denied_paths(vec![
280            "**/.env".into(),
281            "**/.env.*".into(),
282            "**/secrets/**".into(),
283            "**/*.pem".into(),
284        ]);
285
286        // Denied paths (relative)
287        assert!(!caps.path_allowed(".env"));
288        assert!(!caps.path_allowed("config/.env.local"));
289        assert!(!caps.path_allowed("app/secrets/key.txt"));
290        assert!(!caps.path_allowed("certs/server.pem"));
291
292        // Denied paths (absolute — after resolve_path)
293        assert!(!caps.path_allowed("/workspace/.env"));
294        assert!(!caps.path_allowed("/workspace/.env.production"));
295        assert!(!caps.path_allowed("/workspace/secrets/key.txt"));
296        assert!(!caps.path_allowed("/workspace/certs/server.pem"));
297
298        // Normal files still allowed
299        assert!(caps.path_allowed("src/main.rs"));
300        assert!(caps.path_allowed("/workspace/src/main.rs"));
301        assert!(caps.path_allowed("/workspace/README.md"));
302    }
303
304    #[test]
305    fn test_allowed_paths_restriction() {
306        let caps = AgentCapabilities::read_only()
307            .with_allowed_paths(vec!["src/**".into(), "tests/**".into()]);
308
309        assert!(caps.path_allowed("src/main.rs"));
310        assert!(caps.path_allowed("src/lib/utils.rs"));
311        assert!(caps.path_allowed("tests/integration.rs"));
312        assert!(!caps.path_allowed("config/settings.toml"));
313        assert!(!caps.path_allowed("README.md"));
314    }
315
316    #[test]
317    fn test_denied_takes_precedence() {
318        let caps = AgentCapabilities::read_only()
319            .with_denied_paths(vec!["**/secret/**".into()])
320            .with_allowed_paths(vec!["**".into()]);
321
322        assert!(caps.path_allowed("src/main.rs"));
323        assert!(!caps.path_allowed("src/secret/key.txt"));
324    }
325
326    #[test]
327    fn test_client_configured_denied_commands() {
328        let caps = AgentCapabilities::full_access()
329            .with_denied_commands(vec![r"rm\s+-rf\s+/".into(), r"^sudo\s".into()]);
330
331        assert!(!caps.command_allowed("rm -rf /"));
332        assert!(!caps.command_allowed("sudo rm file"));
333
334        // Common shell patterns are NOT blocked
335        assert!(caps.command_allowed("ls -la"));
336        assert!(caps.command_allowed("cargo build"));
337        assert!(caps.command_allowed("unzip file.zip 2>/dev/null"));
338        assert!(caps.command_allowed("python3 -m markitdown file.pptx"));
339    }
340
341    #[test]
342    fn test_allowed_commands_restriction() {
343        let caps = AgentCapabilities::full_access()
344            .with_allowed_commands(vec![r"^cargo ".into(), r"^git ".into()]);
345
346        assert!(caps.command_allowed("cargo build"));
347        assert!(caps.command_allowed("git status"));
348        assert!(!caps.command_allowed("ls -la"));
349        assert!(!caps.command_allowed("npm install"));
350    }
351
352    #[test]
353    fn test_glob_matching() {
354        // Simple wildcards
355        assert!(glob_match("*.rs", "main.rs"));
356        assert!(!glob_match("*.rs", "src/main.rs"));
357
358        // Double star for recursive matching in subdirectories
359        assert!(glob_match("**/*.rs", "src/main.rs"));
360        assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
361
362        // Directory patterns with /** suffix
363        assert!(glob_match("src/**", "src/lib/utils.rs"));
364        assert!(glob_match("src/**", "src/main.rs"));
365
366        // Match files in any subdirectory
367        assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
368        assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
369
370        // Root-level matches need direct pattern
371        assert!(glob_match("test*", "test_main.rs"));
372        assert!(glob_match("test*.rs", "test_main.rs"));
373
374        // Absolute paths (tools resolve to absolute before checking capabilities)
375        assert!(glob_match("**/.env", "/workspace/.env"));
376        assert!(glob_match("**/.env.*", "/workspace/.env.local"));
377        assert!(glob_match("**/secrets/**", "/workspace/secrets/key.txt"));
378        assert!(glob_match("**/*.pem", "/workspace/certs/server.pem"));
379        assert!(glob_match("**/*.key", "/workspace/server.key"));
380        assert!(glob_match("**/id_rsa", "/home/user/.ssh/id_rsa"));
381        assert!(glob_match("**/*.rs", "/Users/dev/project/src/main.rs"));
382
383        // Absolute paths should NOT false-positive
384        assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
385        assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
386    }
387}