An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 375 lines 12 kB view raw
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}