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;
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}