An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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}