1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct AgentCapabilities {
23 pub read: bool,
25 pub write: bool,
27 pub exec: bool,
29 pub allowed_paths: Vec<String>,
31 pub denied_paths: Vec<String>,
33 pub allowed_commands: Vec<String>,
35 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 ".env".into(),
49 ".env.*".into(),
50 "*/.env".into(),
51 "*/.env.*".into(),
52 "**/.env".into(),
53 "**/.env.*".into(),
54 "**/secrets/**".into(),
56 "**/credentials/**".into(),
57 "*.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(), r"rm\s+-r\s+-f\s+/".into(), r"rm\s+-f\s+-r\s+/".into(), r"^sudo\s".into(), r"chmod\s+777".into(), r"mkfs\.".into(), r"dd\s+if=".into(), r">\s*/dev/".into(), ],
76 }
77 }
78}
79
80impl AgentCapabilities {
81 #[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 #[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 #[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 #[must_use]
119 pub const fn with_read(mut self, enabled: bool) -> Self {
120 self.read = enabled;
121 self
122 }
123
124 #[must_use]
126 pub const fn with_write(mut self, enabled: bool) -> Self {
127 self.write = enabled;
128 self
129 }
130
131 #[must_use]
133 pub const fn with_exec(mut self, enabled: bool) -> Self {
134 self.exec = enabled;
135 self
136 }
137
138 #[must_use]
140 pub fn with_allowed_paths(mut self, paths: Vec<String>) -> Self {
141 self.allowed_paths = paths;
142 self
143 }
144
145 #[must_use]
147 pub fn with_denied_paths(mut self, paths: Vec<String>) -> Self {
148 self.denied_paths = paths;
149 self
150 }
151
152 #[must_use]
154 pub fn with_allowed_commands(mut self, commands: Vec<String>) -> Self {
155 self.allowed_commands = commands;
156 self
157 }
158
159 #[must_use]
161 pub fn with_denied_commands(mut self, commands: Vec<String>) -> Self {
162 self.denied_commands = commands;
163 self
164 }
165
166 #[must_use]
168 pub fn can_read(&self, path: &str) -> bool {
169 self.read && self.path_allowed(path)
170 }
171
172 #[must_use]
174 pub fn can_write(&self, path: &str) -> bool {
175 self.write && self.path_allowed(path)
176 }
177
178 #[must_use]
180 pub fn can_exec(&self, command: &str) -> bool {
181 self.exec && self.command_allowed(command)
182 }
183
184 #[must_use]
186 pub fn path_allowed(&self, path: &str) -> bool {
187 for pattern in &self.denied_paths {
189 if glob_match(pattern, path) {
190 return false;
191 }
192 }
193
194 if self.allowed_paths.is_empty() {
196 return true;
197 }
198
199 for pattern in &self.allowed_paths {
201 if glob_match(pattern, path) {
202 return true;
203 }
204 }
205
206 false
207 }
208
209 #[must_use]
211 pub fn command_allowed(&self, command: &str) -> bool {
212 for pattern in &self.denied_commands {
214 if regex_match(pattern, command) {
215 return false;
216 }
217 }
218
219 if self.allowed_commands.is_empty() {
221 return true;
222 }
223
224 for pattern in &self.allowed_commands {
226 if regex_match(pattern, command) {
227 return true;
228 }
229 }
230
231 false
232 }
233}
234
235fn glob_match(pattern: &str, path: &str) -> bool {
237 if pattern == "**" {
239 return true; }
241
242 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 let pattern = escaped
259 .replace("**/", "\x00") .replace("/**", "\x01") .replace('*', "[^/]*") .replace('\x00', "([^/]+/)*") .replace('\x01', "(/.*)?"); let regex = format!("^{pattern}$");
266 regex_match(®ex, path)
267}
268
269fn 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 assert!(!caps.path_allowed(".env"));
286 assert!(!caps.path_allowed(".env.local"));
287
288 assert!(!caps.path_allowed("config/.env"));
290 assert!(!caps.path_allowed("config/.env.local"));
291
292 assert!(!caps.path_allowed("app/secrets/api_key.txt"));
294
295 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![]) .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 assert!(!caps.command_allowed("rm -rf /")); assert!(!caps.command_allowed("sudo rm file")); assert!(!caps.command_allowed("chmod 777 file")); 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")); }
358
359 #[test]
360 fn test_allowed_commands_restriction() {
361 let caps = AgentCapabilities::full_access()
362 .with_denied_commands(vec![]) .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 assert!(glob_match("*.rs", "main.rs"));
375 assert!(!glob_match("*.rs", "src/main.rs"));
376
377 assert!(glob_match("**/*.rs", "src/main.rs"));
379 assert!(glob_match("**/*.rs", "deep/nested/file.rs"));
380
381 assert!(glob_match("src/**", "src/lib/utils.rs"));
383 assert!(glob_match("src/**", "src/main.rs"));
384
385 assert!(glob_match("**/test*", "src/tests/test_utils.rs"));
387 assert!(glob_match("**/test*.rs", "dir/test_main.rs"));
388
389 assert!(glob_match("test*", "test_main.rs"));
391 assert!(glob_match("test*.rs", "test_main.rs"));
392 }
393}