An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
1use glob::{MatchOptions, Pattern};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5/// Represents the type of file operation being checked
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FileOperation {
8 Read,
9 Write,
10 Create,
11}
12
13/// Result of a security scope check
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ScopeCheck {
16 Allowed,
17 Denied(String),
18}
19
20/// Match options that allow `**` to match across directory separators.
21fn glob_match_options() -> MatchOptions {
22 MatchOptions {
23 case_sensitive: true,
24 require_literal_separator: false, // allows `**` to match `/`
25 require_literal_leading_dot: false,
26 }
27}
28
29/// Security scope for an agent, controlling what it can access
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SecurityScope {
32 /// Paths the agent is allowed to access
33 pub allowed_paths: Vec<String>,
34
35 /// Paths the agent is explicitly denied access to
36 pub denied_paths: Vec<String>,
37
38 /// Shell commands the agent is allowed to run
39 pub allowed_commands: Vec<String>,
40
41 /// Whether the agent has read-only access
42 pub read_only: bool,
43
44 /// Whether the agent can create new files
45 pub can_create_files: bool,
46
47 /// Whether the agent can access network resources
48 pub network_access: bool,
49}
50
51impl Default for SecurityScope {
52 fn default() -> Self {
53 Self {
54 allowed_paths: vec!["*".to_string()],
55 denied_paths: vec![],
56 allowed_commands: vec!["*".to_string()],
57 read_only: false,
58 can_create_files: true,
59 network_access: false,
60 }
61 }
62}
63
64impl SecurityScope {
65 /// Check whether a file operation is permitted for the given path.
66 pub fn check_path(&self, path: &str, operation: FileOperation) -> ScopeCheck {
67 // Check read_only constraint
68 if self.read_only && matches!(operation, FileOperation::Write | FileOperation::Create) {
69 return ScopeCheck::Denied("read-only scope".to_string());
70 }
71
72 // Check can_create_files constraint
73 if !self.can_create_files && operation == FileOperation::Create {
74 return ScopeCheck::Denied("file creation not allowed".to_string());
75 }
76
77 let opts = glob_match_options();
78 let path_ref = Path::new(path);
79
80 // Check denied patterns first (deny takes precedence)
81 for pattern_str in &self.denied_paths {
82 if let Ok(pattern) = Pattern::new(pattern_str)
83 && pattern.matches_path_with(path_ref, opts)
84 {
85 return ScopeCheck::Denied(format!("path matches denied pattern: {}", pattern_str));
86 }
87 }
88
89 // Check allowed patterns
90 for pattern_str in &self.allowed_paths {
91 if let Ok(pattern) = Pattern::new(pattern_str)
92 && pattern.matches_path_with(path_ref, opts)
93 {
94 return ScopeCheck::Allowed;
95 }
96 }
97
98 ScopeCheck::Denied("path not in allowed patterns".to_string())
99 }
100
101 /// Check whether a shell command is permitted.
102 pub fn check_command(&self, command: &str) -> ScopeCheck {
103 for pattern_str in &self.allowed_commands {
104 if let Ok(pattern) = Pattern::new(pattern_str)
105 && pattern.matches(command)
106 {
107 return ScopeCheck::Allowed;
108 }
109 }
110
111 ScopeCheck::Denied(format!("command not in allowed patterns: {}", command))
112 }
113
114 /// Check whether network access is permitted.
115 pub fn check_network(&self) -> ScopeCheck {
116 if self.network_access {
117 ScopeCheck::Allowed
118 } else {
119 ScopeCheck::Denied("network access not allowed".to_string())
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 // ============ check_path tests ============
129
130 #[test]
131 fn test_check_path_allowed_simple() {
132 let scope = SecurityScope {
133 allowed_paths: vec!["src/**".to_string()],
134 denied_paths: vec![],
135 allowed_commands: vec![],
136 read_only: false,
137 can_create_files: true,
138 network_access: false,
139 };
140
141 let result = scope.check_path("src/main.rs", FileOperation::Read);
142 assert_eq!(result, ScopeCheck::Allowed);
143 }
144
145 #[test]
146 fn test_check_path_denied_pattern_precedence() {
147 let scope = SecurityScope {
148 allowed_paths: vec!["src/**".to_string()],
149 denied_paths: vec!["src/private/**".to_string()],
150 allowed_commands: vec![],
151 read_only: false,
152 can_create_files: true,
153 network_access: false,
154 };
155
156 // Path matches allowed pattern but also denied pattern - deny should win
157 let result = scope.check_path("src/private/secret.rs", FileOperation::Read);
158 assert!(matches!(result, ScopeCheck::Denied(_)));
159 }
160
161 #[test]
162 fn test_check_path_read_only_blocks_write() {
163 let scope = SecurityScope {
164 allowed_paths: vec!["*".to_string()],
165 denied_paths: vec![],
166 allowed_commands: vec![],
167 read_only: true,
168 can_create_files: false,
169 network_access: false,
170 };
171
172 // Write operation should be blocked by read_only
173 let result = scope.check_path("src/main.rs", FileOperation::Write);
174 assert_eq!(result, ScopeCheck::Denied("read-only scope".to_string()));
175 }
176
177 #[test]
178 fn test_check_path_read_only_allows_read() {
179 let scope = SecurityScope {
180 allowed_paths: vec!["*".to_string()],
181 denied_paths: vec![],
182 allowed_commands: vec![],
183 read_only: true,
184 can_create_files: false,
185 network_access: false,
186 };
187
188 // Read operation should be allowed even when read_only
189 let result = scope.check_path("src/main.rs", FileOperation::Read);
190 assert_eq!(result, ScopeCheck::Allowed);
191 }
192
193 #[test]
194 fn test_check_path_create_blocked_when_disabled() {
195 let scope = SecurityScope {
196 allowed_paths: vec!["*".to_string()],
197 denied_paths: vec![],
198 allowed_commands: vec![],
199 read_only: false,
200 can_create_files: false,
201 network_access: false,
202 };
203
204 // Create operation should be blocked when can_create_files is false
205 let result = scope.check_path("src/newfile.rs", FileOperation::Create);
206 assert_eq!(
207 result,
208 ScopeCheck::Denied("file creation not allowed".to_string())
209 );
210 }
211
212 #[test]
213 fn test_check_path_write_allowed_when_not_readonly() {
214 let scope = SecurityScope {
215 allowed_paths: vec!["*".to_string()],
216 denied_paths: vec![],
217 allowed_commands: vec![],
218 read_only: false,
219 can_create_files: true,
220 network_access: false,
221 };
222
223 // Write operation should be allowed
224 let result = scope.check_path("src/main.rs", FileOperation::Write);
225 assert_eq!(result, ScopeCheck::Allowed);
226 }
227
228 #[test]
229 fn test_check_path_wildcard_matches_everything() {
230 let scope = SecurityScope {
231 allowed_paths: vec!["*".to_string()],
232 denied_paths: vec![],
233 allowed_commands: vec![],
234 read_only: false,
235 can_create_files: true,
236 network_access: false,
237 };
238
239 let result = scope.check_path("anything/anywhere.txt", FileOperation::Read);
240 assert_eq!(result, ScopeCheck::Allowed);
241 }
242
243 #[test]
244 fn test_check_path_recursive_pattern() {
245 let scope = SecurityScope {
246 allowed_paths: vec!["src/**".to_string()],
247 denied_paths: vec![],
248 allowed_commands: vec![],
249 read_only: false,
250 can_create_files: true,
251 network_access: false,
252 };
253
254 // Test that src/** matches nested paths
255 let result = scope.check_path("src/auth/handler.rs", FileOperation::Read);
256 assert_eq!(result, ScopeCheck::Allowed);
257 }
258
259 #[test]
260 fn test_check_path_not_in_allowed() {
261 let scope = SecurityScope {
262 allowed_paths: vec!["src/**".to_string()],
263 denied_paths: vec![],
264 allowed_commands: vec![],
265 read_only: false,
266 can_create_files: true,
267 network_access: false,
268 };
269
270 let result = scope.check_path("tests/something.rs", FileOperation::Read);
271 assert!(
272 matches!(result, ScopeCheck::Denied(ref msg) if msg.contains("path not in allowed patterns"))
273 );
274 }
275
276 // ============ check_command tests ============
277
278 #[test]
279 fn test_check_command_allowed() {
280 let scope = SecurityScope {
281 allowed_paths: vec![],
282 denied_paths: vec![],
283 allowed_commands: vec!["cargo *".to_string()],
284 read_only: false,
285 can_create_files: true,
286 network_access: false,
287 };
288
289 let result = scope.check_command("cargo test");
290 assert_eq!(result, ScopeCheck::Allowed);
291 }
292
293 #[test]
294 fn test_check_command_denied() {
295 let scope = SecurityScope {
296 allowed_paths: vec![],
297 denied_paths: vec![],
298 allowed_commands: vec!["cargo *".to_string()],
299 read_only: false,
300 can_create_files: true,
301 network_access: false,
302 };
303
304 let result = scope.check_command("rm -rf /");
305 assert!(
306 matches!(result, ScopeCheck::Denied(ref msg) if msg.contains("command not in allowed patterns"))
307 );
308 }
309
310 #[test]
311 fn test_check_command_wildcard() {
312 let scope = SecurityScope {
313 allowed_paths: vec![],
314 denied_paths: vec![],
315 allowed_commands: vec!["*".to_string()],
316 read_only: false,
317 can_create_files: true,
318 network_access: false,
319 };
320
321 // Wildcard should match anything
322 let result = scope.check_command("any command here");
323 assert_eq!(result, ScopeCheck::Allowed);
324 }
325
326 #[test]
327 fn test_check_command_exact_match() {
328 let scope = SecurityScope {
329 allowed_paths: vec![],
330 denied_paths: vec![],
331 allowed_commands: vec!["cargo test".to_string()],
332 read_only: false,
333 can_create_files: true,
334 network_access: false,
335 };
336
337 let result = scope.check_command("cargo test");
338 assert_eq!(result, ScopeCheck::Allowed);
339 }
340
341 // ============ check_network tests ============
342
343 #[test]
344 fn test_check_network_allowed() {
345 let scope = SecurityScope {
346 allowed_paths: vec![],
347 denied_paths: vec![],
348 allowed_commands: vec![],
349 read_only: false,
350 can_create_files: true,
351 network_access: true,
352 };
353
354 let result = scope.check_network();
355 assert_eq!(result, ScopeCheck::Allowed);
356 }
357
358 #[test]
359 fn test_check_network_denied() {
360 let scope = SecurityScope {
361 allowed_paths: vec![],
362 denied_paths: vec![],
363 allowed_commands: vec![],
364 read_only: false,
365 can_create_files: true,
366 network_access: false,
367 };
368
369 let result = scope.check_network();
370 assert_eq!(
371 result,
372 ScopeCheck::Denied("network access not allowed".to_string())
373 );
374 }
375}