do_memory_mcp/sandbox/
fs.rs1use anyhow::{Context, Result, bail};
10use std::path::{Path, PathBuf};
11use tracing::{debug, warn};
12
13#[derive(Debug, Clone)]
15pub struct FileSystemRestrictions {
16 pub allowed_paths: Vec<PathBuf>,
18 pub read_only: bool,
20 pub max_path_depth: usize,
22 pub follow_symlinks: bool,
24}
25
26impl Default for FileSystemRestrictions {
27 fn default() -> Self {
28 Self {
29 allowed_paths: vec![],
30 read_only: true,
31 max_path_depth: 10,
32 follow_symlinks: false,
33 }
34 }
35}
36
37impl FileSystemRestrictions {
38 pub fn deny_all() -> Self {
40 Self {
41 allowed_paths: vec![],
42 read_only: true,
43 max_path_depth: 10,
44 follow_symlinks: false,
45 }
46 }
47
48 pub fn read_only(allowed_paths: Vec<PathBuf>) -> Self {
50 Self {
51 allowed_paths,
52 read_only: true,
53 max_path_depth: 10,
54 follow_symlinks: false,
55 }
56 }
57
58 pub fn read_write(allowed_paths: Vec<PathBuf>) -> Self {
60 Self {
61 allowed_paths,
62 read_only: false,
63 max_path_depth: 10,
64 follow_symlinks: false,
65 }
66 }
67
68 pub fn validate_read_path(&self, path: &Path) -> Result<PathBuf> {
78 self.validate_path(path, false)
79 }
80
81 pub fn validate_write_path(&self, path: &Path) -> Result<PathBuf> {
83 if self.read_only {
84 bail!(SecurityError::WriteAccessDenied {
85 path: path.to_string_lossy().to_string()
86 });
87 }
88 self.validate_path(path, true)
89 }
90
91 fn validate_path(&self, path: &Path, _is_write: bool) -> Result<PathBuf> {
93 if self.allowed_paths.is_empty() {
95 bail!(SecurityError::FileSystemAccessDenied {
96 reason: "No file system access allowed (empty whitelist)".to_string()
97 });
98 }
99
100 let sanitized = sanitize_path(path)?;
102
103 let depth = sanitized.components().count();
105 if depth > self.max_path_depth {
106 bail!(SecurityError::PathTooDeep {
107 path: sanitized.to_string_lossy().to_string(),
108 depth,
109 max_depth: self.max_path_depth
110 });
111 }
112
113 let resolved = if self.follow_symlinks {
115 canonicalize_path(&sanitized)?
116 } else {
117 sanitized.clone()
118 };
119
120 let allowed = self.is_path_allowed(&resolved)?;
122 if !allowed {
123 warn!(
124 "Path access denied: {} (not in whitelist)",
125 resolved.display()
126 );
127 bail!(SecurityError::PathNotInWhitelist {
128 path: resolved.to_string_lossy().to_string(),
129 allowed_paths: self
130 .allowed_paths
131 .iter()
132 .map(|p| p.to_string_lossy().to_string())
133 .collect()
134 });
135 }
136
137 debug!("Path validated: {}", resolved.display());
138 Ok(resolved)
139 }
140
141 fn is_path_allowed(&self, path: &Path) -> Result<bool> {
143 let canonical_path = canonicalize_path(path)?;
145
146 for allowed_path in &self.allowed_paths {
147 let canonical_allowed = canonicalize_path(allowed_path)?;
149
150 if canonical_path.starts_with(&canonical_allowed) {
152 return Ok(true);
153 }
154
155 if canonical_path == canonical_allowed {
157 return Ok(true);
158 }
159 }
160
161 Ok(false)
162 }
163}
164
165fn sanitize_path(path: &Path) -> Result<PathBuf> {
167 let mut sanitized = PathBuf::new();
168 let mut depth = 0i32;
169
170 for component in path.components() {
171 match component {
172 std::path::Component::Prefix(_) => {
173 sanitized.push(component);
175 }
176 std::path::Component::RootDir => {
177 sanitized.push(component);
178 depth = 0;
179 }
180 std::path::Component::CurDir => {
181 continue;
183 }
184 std::path::Component::ParentDir => {
185 if depth > 0 {
187 sanitized.pop();
188 depth -= 1;
189 } else {
190 bail!(SecurityError::PathTraversalAttempt {
192 path: path.to_string_lossy().to_string()
193 });
194 }
195 }
196 std::path::Component::Normal(name) => {
197 let name_str = name.to_string_lossy();
199 if is_suspicious_filename(&name_str) {
200 bail!(SecurityError::SuspiciousFilename {
201 filename: name_str.to_string()
202 });
203 }
204
205 sanitized.push(component);
206 depth += 1;
207 }
208 }
209 }
210
211 Ok(sanitized)
212}
213
214fn canonicalize_path(path: &Path) -> Result<PathBuf> {
216 if path.exists() {
218 return path
219 .canonicalize()
220 .context("Failed to canonicalize existing path");
221 }
222
223 let mut current = path.to_path_buf();
225 let mut missing_components = Vec::new();
226
227 loop {
228 if current.exists() {
229 let canonical_base = current
231 .canonicalize()
232 .context("Failed to canonicalize ancestor path")?;
233
234 let mut result = canonical_base;
236 for component in missing_components.iter().rev() {
237 result.push(component);
238 }
239
240 return Ok(result);
241 }
242
243 if let Some(file_name) = current.file_name() {
245 missing_components.push(file_name.to_os_string());
246 if let Some(parent) = current.parent() {
247 current = parent.to_path_buf();
248 } else {
249 return Ok(path.to_path_buf());
252 }
253 } else {
254 return Ok(path.to_path_buf());
256 }
257 }
258}
259
260fn is_suspicious_filename(name: &str) -> bool {
262 if name.contains('\0') {
264 return true;
265 }
266
267 if name.chars().any(|c| c.is_control()) {
269 return true;
270 }
271
272 if name.chars().any(|c| {
274 matches!(
275 c,
276 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' )
281 }) {
282 return true;
283 }
284
285 false
286}
287
288#[derive(Debug, thiserror::Error)]
290pub enum SecurityError {
291 #[error("File system access denied: {reason}")]
292 FileSystemAccessDenied { reason: String },
293
294 #[error("Write access denied for path: {path}")]
295 WriteAccessDenied { path: String },
296
297 #[error("Path not in whitelist: {path} (allowed: {allowed_paths:?})")]
298 PathNotInWhitelist {
299 path: String,
300 allowed_paths: Vec<String>,
301 },
302
303 #[error("Path too deep: {path} (depth: {depth}, max: {max_depth})")]
304 PathTooDeep {
305 path: String,
306 depth: usize,
307 max_depth: usize,
308 },
309
310 #[error("Path traversal attempt detected: {path}")]
311 PathTraversalAttempt { path: String },
312
313 #[error("Suspicious filename detected: {filename}")]
314 SuspiciousFilename { filename: String },
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::fs;
321 use tempfile::TempDir;
322
323 #[test]
324 fn test_sanitize_path_basic() {
325 let path = Path::new("/tmp/test");
326 let sanitized = sanitize_path(path).unwrap();
327 assert_eq!(sanitized, PathBuf::from("/tmp/test"));
328 }
329
330 #[test]
331 fn test_sanitize_path_removes_current_dir() {
332 let path = Path::new("/tmp/./test");
333 let sanitized = sanitize_path(path).unwrap();
334 assert_eq!(sanitized, PathBuf::from("/tmp/test"));
335 }
336
337 #[test]
338 fn test_sanitize_path_handles_parent_dir() {
339 let path = Path::new("/tmp/foo/../test");
340 let sanitized = sanitize_path(path).unwrap();
341 assert_eq!(sanitized, PathBuf::from("/tmp/test"));
342 }
343
344 #[test]
345 fn test_sanitize_path_prevents_traversal_above_root() {
346 let path = Path::new("/../etc/passwd");
347 let result = sanitize_path(path);
348 assert!(result.is_err());
349 }
350
351 #[test]
352 fn test_is_suspicious_filename_null_byte() {
353 assert!(is_suspicious_filename("file\0name"));
354 }
355
356 #[test]
357 fn test_is_suspicious_filename_control_chars() {
358 assert!(is_suspicious_filename("file\nname"));
359 assert!(is_suspicious_filename("file\rname"));
360 }
361
362 #[test]
363 fn test_is_suspicious_filename_zero_width() {
364 assert!(is_suspicious_filename("file\u{200B}name"));
365 }
366
367 #[test]
368 fn test_is_suspicious_filename_normal() {
369 assert!(!is_suspicious_filename("normal_file.txt"));
370 assert!(!is_suspicious_filename("file-name.json"));
371 }
372
373 #[test]
374 fn test_deny_all() {
375 let restrictions = FileSystemRestrictions::deny_all();
376 let result = restrictions.validate_read_path(Path::new("/tmp/test"));
377 assert!(result.is_err());
378 }
379
380 #[test]
381 fn test_read_only_mode_denies_writes() {
382 let temp_dir = TempDir::new().unwrap();
383 let restrictions = FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
384
385 let test_path = temp_dir.path().join("test.txt");
386 let result = restrictions.validate_write_path(&test_path);
387 assert!(result.is_err());
388 }
389
390 #[test]
391 fn test_whitelist_allows_subdirectories() {
392 let temp_dir = TempDir::new().unwrap();
393 let sub_dir = temp_dir.path().join("subdir");
394 fs::create_dir_all(&sub_dir).unwrap();
395
396 let restrictions = FileSystemRestrictions::read_write(vec![temp_dir.path().to_path_buf()]);
397
398 let test_path = sub_dir.join("test.txt");
399 let result = restrictions.validate_write_path(&test_path);
400 assert!(result.is_ok());
401 }
402
403 #[test]
404 fn test_whitelist_denies_outside_paths() {
405 let temp_dir = TempDir::new().unwrap();
406 let restrictions = FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
407
408 let outside_path = Path::new("/etc/passwd");
409 let result = restrictions.validate_read_path(outside_path);
410 assert!(result.is_err());
411 }
412
413 #[test]
414 fn test_path_depth_limit() {
415 let temp_dir = TempDir::new().unwrap();
416 let mut restrictions =
417 FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
418 restrictions.max_path_depth = 2;
419
420 let deep_path = temp_dir.path().join("a/b/c/d/e/f/g");
422
423 let result = restrictions.validate_read_path(&deep_path);
424 assert!(result.is_err());
425 }
426}