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