1use super::tool::RiskLevel;
2use std::path::{Path, PathBuf};
3
4#[allow(dead_code)]
5pub const PROTECTED_FILES: &[&str] = &[
6 "C:\\Windows",
8 "C:\\Program Files",
9 "C:\\$Recycle.Bin",
10 "System Volume Information",
11 "C:\\Users\\Default",
12 "/etc",
14 "/dev",
15 "/proc",
16 "/sys",
17 "/root",
18 "/var/log",
19 "/boot",
20 ".bashrc",
22 ".zshrc",
23 ".bash_history",
24 ".gitconfig",
25 ".ssh/",
26 ".aws/",
27 ".env",
28 "credentials.json",
29 "auth.json",
30 "id_rsa",
31 ".mcp.json",
33 "hematite_memory.db",
34];
35
36#[allow(dead_code)]
39pub fn path_is_safe(workspace_root: &Path, target: &Path) -> Result<PathBuf, String> {
40 let mut target_str = target.to_string_lossy().to_string().to_lowercase();
42 target_str = target_str
43 .replace("\\", "/")
44 .replace("\u{005c}", "/")
45 .replace("%5c", "/");
46
47 for protected in PROTECTED_FILES {
49 let prot_lower = protected.to_lowercase().replace("\\", "/");
50 if target_str.contains(&prot_lower) {
51 return Err(format!(
52 "AccessDenied: Path {} hits the Hematite Security Blacklist natively: {}",
53 target_str, protected
54 ));
55 }
56 }
57
58 let resolved_path = match std::fs::canonicalize(target) {
60 Ok(p) => p,
61 Err(_) => {
62 let parent = target.parent().unwrap_or(Path::new(""));
64 let mut resolved_parent = std::fs::canonicalize(parent)
65 .map_err(|_| "AccessDenied: Invalid directory ancestry inside sandbox root. Path traversing halted!".to_string())?;
66 if let Some(name) = target.file_name() {
67 resolved_parent.push(name);
68 }
69 resolved_parent
70 }
71 };
72
73 let resolved_str = resolved_path
75 .to_string_lossy()
76 .to_string()
77 .to_lowercase()
78 .replace("\\", "/");
79 for protected in PROTECTED_FILES {
80 let prot_lower = protected.to_lowercase().replace("\\", "/");
81 if resolved_str.contains(&prot_lower) {
82 return Err(format!(
83 "AccessDenied: Canonicalized Sandbox resolution natively hits Blacklist bounds: {}",
84 protected
85 ));
86 }
87 }
88
89 let resolved_workspace = std::fs::canonicalize(workspace_root).unwrap_or_default();
90
91 if !resolved_path.starts_with(&resolved_workspace) {
93 if target.is_absolute() {
95 return Ok(resolved_path);
96 }
97 return Err(format!("AccessDenied: ⛔ SANDBOX BREACHED ⛔ Attempted directory traversal outside project bounds: {:?}", resolved_path));
98 }
99
100 Ok(resolved_path)
101}
102
103#[allow(dead_code)]
105pub fn bash_is_safe(cmd: &str) -> Result<(), String> {
106 let lower = cmd
107 .to_lowercase()
108 .replace("\\", "/")
109 .replace("\u{005c}", "/")
110 .replace("%5c", "/");
111 for protected in PROTECTED_FILES {
112 let prot_lower = protected.to_lowercase().replace("\\", "/");
113 if lower.contains(&prot_lower) {
114 return Err(format!("AccessDenied: Bash command structurally attempts to manipulate blacklisted system area: {}", protected));
115 }
116 }
117
118 let sandbox_redirects = [
121 "deno run",
122 "deno --version",
123 "deno -v",
124 "python -c ",
125 "python3 -c ",
126 "node -e ",
127 "node --eval",
128 ];
129 for pattern in sandbox_redirects {
130 if lower.contains(pattern) {
131 return Err(format!(
132 "Use the run_code tool instead of shell for executing {} code. \
133 Shell is blocked for sandbox-style execution.",
134 pattern.split_whitespace().next().unwrap_or("code")
135 ));
136 }
137 }
138
139 Ok(())
140}
141
142pub fn classify_bash_risk(cmd: &str) -> RiskLevel {
148 let lower = cmd.to_lowercase();
149
150 let high = [
152 "rm -",
154 "rm /",
155 "del /",
156 "del /f",
157 "rmdir /s",
158 "remove-item -r",
159 "curl ",
161 "wget ",
162 "invoke-webrequest",
163 "invoke-restmethod",
164 "fetch ",
165 "sudo ",
167 "runas ",
168 "su -",
169 "git push",
171 "git force",
172 "git reset --hard",
173 "git clean -f",
174 "shutdown",
176 "restart-computer",
177 "taskkill",
178 "format-volume",
179 "diskpart",
180 "format c",
181 "del c:\\",
182 ".ssh/",
184 ".aws/",
185 "credentials.json",
186 ];
187 if high.iter().any(|p| lower.contains(p)) {
188 return RiskLevel::High;
189 }
190
191 let safe_prefixes = [
193 "cargo check",
194 "cargo build",
195 "cargo test",
196 "cargo fmt",
197 "cargo clippy",
198 "cargo run",
199 "cargo doc",
200 "cargo tree",
201 "rustc ",
202 "rustfmt ",
203 "git status",
204 "git log",
205 "git diff",
206 "git branch",
207 "git show",
208 "git stash list",
209 "git remote -v",
210 "ls ",
211 "ls\n",
212 "dir ",
213 "dir\n",
214 "echo ",
215 "pwd",
216 "whoami",
217 "cat ",
218 "type ",
219 "head ",
220 "tail ",
221 "get-childitem",
222 "get-content",
223 "get-location",
224 "cargo --version",
225 "rustc --version",
226 "git --version",
227 "node --version",
228 "npm --version",
229 "python --version",
230 "grep ",
232 "grep\n",
233 "rg ",
234 "rg\n",
235 "find ",
236 "find\n",
237 "select-string",
238 "select-object",
239 "where-object",
240 "sort ",
241 "sort\n",
242 "wc ",
243 "uniq ",
244 "cut ",
245 "file ",
246 "stat ",
247 "du ",
248 "df ",
249 "powershell -command \"select-string",
251 "powershell -command \"get-childitem",
252 "powershell -command \"get-content",
253 "powershell -command 'select-string",
254 "powershell -command 'get-childitem",
255 ];
256 if safe_prefixes
257 .iter()
258 .any(|p| lower.starts_with(p) || lower == p.trim())
259 {
260 return RiskLevel::Safe;
261 }
262
263 RiskLevel::Moderate
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::path::Path;
271
272 #[test]
273 fn test_blacklist_windows_system() {
274 let root = Path::new("C:\\Users\\ocean\\Project");
276 let target = Path::new("C:\\Windows\\System32\\cmd.exe");
277 let result = path_is_safe(root, target);
278 assert!(
279 result.is_err(),
280 "Windows System directory should be blocked!"
281 );
282 assert!(result.unwrap_err().contains("Security Blacklist"));
283 }
284
285 #[test]
286 fn test_relative_parent_traversal_is_blocked() {
287 let root = std::env::current_dir().unwrap();
288 let result = path_is_safe(&root, Path::new(".."));
289 assert!(
290 result.is_err(),
291 "Relative traversal outside of workspace root should be blocked!"
292 );
293 assert!(result.unwrap_err().contains("SANDBOX BREACHED"));
294 }
295
296 #[test]
297 fn test_absolute_outside_path_is_allowed_when_not_blacklisted() {
298 let root = std::env::current_dir().unwrap();
299 if let Some(parent) = root.parent() {
300 let result = path_is_safe(&root, parent);
301 assert!(
302 result.is_ok(),
303 "Absolute non-blacklisted paths should follow the relaxed sandbox policy."
304 );
305 }
306 }
307
308 #[test]
309 fn test_bash_blacklist() {
310 let cmd = "ls C:\\Windows";
311 let result = bash_is_safe(cmd);
312 assert!(
313 result.is_err(),
314 "Bash command touching Windows should be blocked!"
315 );
316 assert!(result.unwrap_err().contains("blacklisted system area"));
317 }
318
319 #[test]
320 fn test_risk_classification() {
321 assert_eq!(classify_bash_risk("cargo check"), RiskLevel::Safe);
322 assert_eq!(classify_bash_risk("rm -rf /"), RiskLevel::High);
323 assert_eq!(classify_bash_risk("mkdir new_dir"), RiskLevel::Moderate);
324 }
325}