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