An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 172 lines 5.4 kB view raw
1use crate::config::{SecurityConfig, ShellPolicy}; 2use anyhow::{Result, anyhow}; 3use regex::Regex; 4use std::path::{Component, Path, PathBuf}; 5 6pub mod permission; 7pub mod scope; 8 9pub use scope::{FileOperation, ScopeCheck, SecurityScope}; 10 11pub struct SecurityValidator { 12 config: SecurityConfig, 13 blocked_regexes: Vec<Regex>, 14 allowed_paths_canonical: Vec<PathBuf>, 15} 16 17#[derive(Debug)] 18pub enum ValidationResult { 19 Allowed, 20 Denied(String), 21 RequiresPermission(String), 22} 23 24impl SecurityValidator { 25 pub fn new(config: SecurityConfig) -> Result<Self> { 26 // Compile blocked patterns into regexes 27 let blocked_regexes = config 28 .blocked_patterns 29 .iter() 30 .map(|p| Regex::new(p)) 31 .collect::<Result<Vec<_>, _>>() 32 .map_err(|e| anyhow!("Invalid regex pattern: {}", e))?; 33 34 // Canonicalize allowed paths 35 let allowed_paths_canonical = config 36 .allowed_paths 37 .iter() 38 .map(|p| { 39 let expanded = shellexpand::tilde(p); 40 PathBuf::from(expanded.as_ref()) 41 .canonicalize() 42 .unwrap_or_else(|_| PathBuf::from(expanded.as_ref())) 43 }) 44 .collect(); 45 46 Ok(Self { 47 config, 48 blocked_regexes, 49 allowed_paths_canonical, 50 }) 51 } 52 53 pub fn validate_shell_command(&self, command: &str) -> ValidationResult { 54 match self.config.shell_policy { 55 ShellPolicy::Unrestricted => ValidationResult::Allowed, 56 57 ShellPolicy::Allowlist => { 58 // Extract base command (first word) 59 let base_cmd = command.split_whitespace().next().unwrap_or(""); 60 61 if self.config.allowed_commands.contains(&base_cmd.to_string()) { 62 ValidationResult::Allowed 63 } else { 64 ValidationResult::RequiresPermission(format!( 65 "Command '{}' not in allowlist", 66 base_cmd 67 )) 68 } 69 } 70 71 ShellPolicy::Blocklist => { 72 for pattern in &self.blocked_regexes { 73 if pattern.is_match(command) { 74 return ValidationResult::Denied(format!( 75 "Command matches blocked pattern: {}", 76 pattern 77 )); 78 } 79 } 80 ValidationResult::Allowed 81 } 82 } 83 } 84 85 pub fn validate_file_path(&self, path: &Path) -> ValidationResult { 86 let path_str = path.to_string_lossy(); 87 let expanded = shellexpand::tilde(&path_str); 88 let path = PathBuf::from(expanded.as_ref()); 89 90 let canonical = match path.canonicalize() { 91 Ok(p) => p, 92 Err(_) => match resolve_nonexistent_path(&path) { 93 Ok(p) => p, 94 Err(reason) => return ValidationResult::Denied(reason), 95 }, 96 }; 97 98 for allowed in &self.allowed_paths_canonical { 99 if canonical.starts_with(allowed) { 100 return ValidationResult::Allowed; 101 } 102 } 103 104 ValidationResult::RequiresPermission(format!( 105 "Path '{}' is outside allowed directories", 106 path.display() 107 )) 108 } 109 110 pub fn check_file_size(&self, path: &Path) -> ValidationResult { 111 match std::fs::metadata(path) { 112 Ok(metadata) => { 113 let size_mb = metadata.len() / (1024 * 1024); 114 if size_mb <= self.config.max_file_size_mb { 115 ValidationResult::Allowed 116 } else { 117 ValidationResult::Denied(format!( 118 "File size {}MB exceeds limit of {}MB", 119 size_mb, self.config.max_file_size_mb 120 )) 121 } 122 } 123 Err(e) => ValidationResult::Denied(format!("Cannot check file size: {}", e)), 124 } 125 } 126} 127 128fn resolve_nonexistent_path(path: &Path) -> Result<PathBuf, String> { 129 let components: Vec<Component> = path.components().collect(); 130 131 for i in (0..=components.len()).rev() { 132 let ancestor: PathBuf = components[..i].iter().collect(); 133 134 if ancestor.as_os_str().is_empty() { 135 if let Ok(canonical) = std::env::current_dir() { 136 let suffix = &components[i..]; 137 return validate_and_build_path(canonical, suffix); 138 } 139 continue; 140 } 141 142 if let Ok(canonical) = ancestor.canonicalize() { 143 let suffix = &components[i..]; 144 return validate_and_build_path(canonical, suffix); 145 } 146 } 147 148 Err("cannot resolve path".to_string()) 149} 150 151fn validate_and_build_path(base: PathBuf, suffix: &[Component]) -> Result<PathBuf, String> { 152 let mut resolved = base; 153 154 for component in suffix { 155 match component { 156 Component::ParentDir => { 157 return Err("path contains invalid traversal (..)".to_string()); 158 } 159 Component::CurDir => { 160 continue; 161 } 162 Component::Normal(name) => { 163 resolved = resolved.join(name); 164 } 165 _ => { 166 return Err("invalid path component in suffix".to_string()); 167 } 168 } 169 } 170 171 Ok(resolved) 172}