agent_sdk/
capabilities.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Default, Serialize, Deserialize)]
25pub struct AgentCapabilities {
26 pub read: bool,
28 pub write: bool,
30 pub exec: bool,
32 pub allowed_paths: Vec<String>,
34 pub denied_paths: Vec<String>,
36 pub allowed_commands: Vec<String>,
38 pub denied_commands: Vec<String>,
40}
41
42impl AgentCapabilities {
43 #[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 #[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 #[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 #[must_use]
87 pub const fn with_read(mut self, enabled: bool) -> Self {
88 self.read = enabled;
89 self
90 }
91
92 #[must_use]
94 pub const fn with_write(mut self, enabled: bool) -> Self {
95 self.write = enabled;
96 self
97 }
98
99 #[must_use]
101 pub const fn with_exec(mut self, enabled: bool) -> Self {
102 self.exec = enabled;
103 self
104 }
105
106 #[must_use]
108 pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
109 self.allowed_paths = paths;
110 self
111 }
112
113 #[must_use]
115 pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
116 self.denied_paths = paths;
117 self
118 }
119
120 #[must_use]
122 pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
123 self.allowed_commands = commands;
124 self
125 }
126
127 #[must_use]
129 pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
130 self.denied_commands = commands;
131 self
132 }
133
134 #[must_use]
136 pub fn can_read(&self, path: &str) -> bool {
137 self.read && self.path_allowed(path)
138 }
139
140 #[must_use]
142 pub fn can_write(&self, path: &str) -> bool {
143 self.write && self.path_allowed(path)
144 }
145
146 #[must_use]
148 pub fn can_exec(&self, command: &str) -> bool {
149 self.exec && self.command_allowed(command)
150 }
151
152 #[must_use]
154 pub fn path_allowed(&self, path: &str) -> bool {
155 for pattern in &self.denied_paths {
157 if glob_match(pattern, path) {
158 return false;
159 }
160 }
161
162 if self.allowed_paths.is_empty() {
164 return true;
165 }
166
167 for pattern in &self.allowed_paths {
169 if glob_match(pattern, path) {
170 return true;
171 }
172 }
173
174 false
175 }
176
177 #[must_use]
179 pub fn command_allowed(&self, command: &str) -> bool {
180 for pattern in &self.denied_commands {
182 if regex_match(pattern, command) {
183 return false;
184 }
185 }
186
187 if self.allowed_commands.is_empty() {
189 return true;
190 }
191
192 for pattern in &self.allowed_commands {
194 if regex_match(pattern, command) {
195 return true;
196 }
197 }
198
199 false
200 }
201}
202
203fn glob_match(pattern: &str, path: &str) -> bool {
205 if pattern == "**" {
207 return true; }
209
210 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 let pattern = escaped
227 .replace("**/", "\x00") .replace("/**", "\x01") .replace('*', "[^/]*") .replace('\x00', "(.*/)?") .replace('\x01', "(/.*)?"); let regex = format!("^{pattern}$");
234 regex_match(®ex, path)
235}
236
237fn 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 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 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 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 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 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 assert!(glob_match("*.rs", "main.rs"));
356 assert!(!glob_match("*.rs", "src/main.rs"));
357
358 assert!(glob_match("**/*.rs", "src/main.rs"));
360 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
361
362 assert!(glob_match("src/**", "src/lib/utils.rs"));
364 assert!(glob_match("src/**", "src/main.rs"));
365
366 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
368 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
369
370 assert!(glob_match("test*", "test_main.rs"));
372 assert!(glob_match("test*.rs", "test_main.rs"));
373
374 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 assert!(!glob_match("**/.env", "/workspace/src/main.rs"));
385 assert!(!glob_match("**/*.pem", "/workspace/src/lib.rs"));
386 }
387}