Skip to main content

claude_agent/security/fs/
mod.rs

1//! Secure filesystem operations with TOCTOU protection.
2
3mod handle;
4
5pub use handle::SecureFileHandle;
6
7use std::os::unix::io::OwnedFd;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use glob::Pattern;
12
13use super::SecurityError;
14use super::path::{SafePath, normalize_path};
15use crate::permissions::ToolLimits;
16
17#[derive(Clone)]
18pub struct SecureFs {
19    root_fd: Arc<OwnedFd>,
20    root_path: PathBuf,
21    allowed_paths: Vec<PathBuf>,
22    denied_patterns: Vec<Pattern>,
23    max_symlink_depth: u8,
24    permissive: bool,
25}
26
27impl SecureFs {
28    pub fn new(
29        root: &Path,
30        allowed_paths: Vec<PathBuf>,
31        denied_patterns: &[String],
32        max_symlink_depth: u8,
33    ) -> Result<Self, SecurityError> {
34        let root_path = if root.exists() {
35            std::fs::canonicalize(root)?
36        } else {
37            normalize_path(root)
38        };
39
40        let root_fd = std::fs::File::open(&root_path)?;
41
42        let compiled_patterns = denied_patterns
43            .iter()
44            .filter_map(|p| Pattern::new(p).ok())
45            .collect();
46
47        Ok(Self {
48            root_fd: Arc::new(root_fd.into()),
49            root_path,
50            allowed_paths: allowed_paths
51                .into_iter()
52                .filter_map(|p| {
53                    if p.exists() {
54                        std::fs::canonicalize(&p).ok()
55                    } else {
56                        Some(normalize_path(&p))
57                    }
58                })
59                .collect(),
60            denied_patterns: compiled_patterns,
61            max_symlink_depth,
62            permissive: false,
63        })
64    }
65
66    /// Create a permissive SecureFs that allows all operations.
67    ///
68    /// # Panics
69    /// Panics if "/" cannot be opened, which indicates a fundamentally broken system.
70    pub fn permissive() -> Self {
71        let root_fd = std::fs::File::open("/").expect("failed to open root directory");
72        Self {
73            root_fd: Arc::new(root_fd.into()),
74            root_path: PathBuf::from("/"),
75            allowed_paths: Vec::new(),
76            denied_patterns: Vec::new(),
77            max_symlink_depth: 255,
78            permissive: true,
79        }
80    }
81
82    pub fn is_permissive(&self) -> bool {
83        self.permissive
84    }
85
86    pub fn root(&self) -> &Path {
87        &self.root_path
88    }
89
90    pub fn resolve(&self, input_path: &str) -> Result<SafePath, SecurityError> {
91        if input_path.contains('\0') {
92            return Err(SecurityError::InvalidPath("null byte in path".into()));
93        }
94
95        if input_path.is_empty() {
96            return Err(SecurityError::InvalidPath("empty path".into()));
97        }
98
99        if self.permissive {
100            let resolved = if input_path.starts_with('/') {
101                PathBuf::from(input_path)
102            } else {
103                self.root_path.join(input_path)
104            };
105            let normalized = if resolved.exists() {
106                std::fs::canonicalize(&resolved)?
107            } else if let Some(parent) = resolved.parent() {
108                if parent.exists() {
109                    std::fs::canonicalize(parent)?.join(resolved.file_name().unwrap_or_default())
110                } else {
111                    normalize_path(&resolved)
112                }
113            } else {
114                normalize_path(&resolved)
115            };
116            return Ok(SafePath::unchecked(Arc::clone(&self.root_fd), normalized));
117        }
118
119        let relative = if input_path.starts_with('/') {
120            let input = PathBuf::from(input_path);
121            let normalized_input = if input.exists() {
122                std::fs::canonicalize(&input)?
123            } else if let Some(parent) = input.parent() {
124                if parent.exists() {
125                    std::fs::canonicalize(parent)?.join(input.file_name().unwrap_or_default())
126                } else {
127                    normalize_path(&input)
128                }
129            } else {
130                normalize_path(&input)
131            };
132
133            if normalized_input.starts_with(&self.root_path) {
134                normalized_input
135                    .strip_prefix(&self.root_path)
136                    .map(|p| p.to_path_buf())
137                    .unwrap_or_default()
138            } else {
139                let mut found = None;
140                for allowed in &self.allowed_paths {
141                    if normalized_input.starts_with(allowed) {
142                        found = Some(
143                            normalized_input
144                                .strip_prefix(allowed)
145                                .map(|p| p.to_path_buf())
146                                .unwrap_or_default(),
147                        );
148                        break;
149                    }
150                }
151                match found {
152                    Some(rel) => rel,
153                    None => return Err(SecurityError::PathEscape(normalized_input)),
154                }
155            }
156        } else {
157            normalize_path(&PathBuf::from(input_path))
158                .strip_prefix("/")
159                .map(|p| p.to_path_buf())
160                .unwrap_or_else(|_| PathBuf::from(input_path))
161        };
162
163        let expected_path = self.root_path.join(&relative);
164        if self.is_path_denied(&expected_path) {
165            return Err(SecurityError::DeniedPath(expected_path));
166        }
167
168        let safe_path = SafePath::resolve(
169            Arc::clone(&self.root_fd),
170            self.root_path.clone(),
171            &relative,
172            self.max_symlink_depth,
173        )?;
174
175        let resolved = safe_path.as_path();
176        if !self.is_within(resolved) {
177            return Err(SecurityError::PathEscape(resolved.to_path_buf()));
178        }
179        if self.is_path_denied(resolved) {
180            return Err(SecurityError::DeniedPath(resolved.to_path_buf()));
181        }
182
183        Ok(safe_path)
184    }
185
186    pub fn resolve_with_limits(
187        &self,
188        input_path: &str,
189        limits: &ToolLimits,
190    ) -> Result<SafePath, SecurityError> {
191        let path = self.resolve(input_path)?;
192        let full_path = path.as_path();
193        let path_str = full_path.to_string_lossy();
194
195        if let Some(ref allowed) = limits.allowed_paths
196            && !allowed.is_empty()
197        {
198            let allowed_patterns: Vec<Pattern> = allowed
199                .iter()
200                .filter_map(|p| Pattern::new(p).ok())
201                .collect();
202            if !allowed_patterns.iter().any(|p| p.matches(&path_str)) {
203                return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
204            }
205        }
206
207        if let Some(ref denied) = limits.denied_paths {
208            let denied_patterns: Vec<Pattern> =
209                denied.iter().filter_map(|p| Pattern::new(p).ok()).collect();
210            if denied_patterns.iter().any(|p| p.matches(&path_str)) {
211                return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
212            }
213        }
214
215        Ok(path)
216    }
217
218    pub fn open_read(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
219        let path = self.resolve(input_path)?;
220        SecureFileHandle::open_read(path)
221    }
222
223    pub fn open_write(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
224        let path = self.resolve(input_path)?;
225        SecureFileHandle::open_write(path)
226    }
227
228    pub fn is_within(&self, path: &Path) -> bool {
229        if self.permissive {
230            return true;
231        }
232
233        let canonical = self.resolve_to_canonical(path);
234        canonical.starts_with(&self.root_path)
235            || self.allowed_paths.iter().any(|p| canonical.starts_with(p))
236    }
237
238    fn resolve_to_canonical(&self, path: &Path) -> PathBuf {
239        if let Ok(p) = std::fs::canonicalize(path) {
240            return p;
241        }
242
243        let mut current = path.to_path_buf();
244        let mut components_to_append = Vec::new();
245
246        while let Some(parent) = current.parent() {
247            if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
248                let mut result = canonical_parent;
249                if let Some(name) = current.file_name() {
250                    result = result.join(name);
251                }
252                for component in components_to_append.into_iter().rev() {
253                    result = result.join(component);
254                }
255                return result;
256            }
257            if let Some(name) = current.file_name() {
258                components_to_append.push(name.to_os_string());
259            }
260            current = parent.to_path_buf();
261        }
262
263        normalize_path(path)
264    }
265
266    fn is_path_denied(&self, path: &Path) -> bool {
267        let path_str = path.to_string_lossy();
268        self.denied_patterns
269            .iter()
270            .any(|pattern| pattern.matches(&path_str))
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::fs;
278    use tempfile::tempdir;
279
280    #[test]
281    fn test_secure_fs_new() {
282        let dir = tempdir().unwrap();
283        let fs = SecureFs::new(dir.path(), vec![], &[], 10).unwrap();
284        assert_eq!(fs.root(), std::fs::canonicalize(dir.path()).unwrap());
285    }
286
287    #[test]
288    fn test_resolve_valid_path() {
289        let dir = tempdir().unwrap();
290        let root = std::fs::canonicalize(dir.path()).unwrap();
291        fs::write(root.join("test.txt"), "content").unwrap();
292
293        let secure_fs = SecureFs::new(&root, vec![], &[], 10).unwrap();
294        let path = secure_fs.resolve("test.txt").unwrap();
295        assert_eq!(path.as_path(), root.join("test.txt"));
296    }
297
298    #[test]
299    fn test_resolve_path_escape_blocked() {
300        let dir = tempdir().unwrap();
301        let secure_fs = SecureFs::new(dir.path(), vec![], &[], 10).unwrap();
302        let result = secure_fs.resolve("../../../etc/passwd");
303        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
304    }
305
306    #[test]
307    fn test_denied_patterns() {
308        let dir = tempdir().unwrap();
309        let root = std::fs::canonicalize(dir.path()).unwrap();
310        fs::write(root.join("secret.key"), "secret").unwrap();
311
312        let patterns = vec!["*.key".to_string()];
313        let secure_fs = SecureFs::new(&root, vec![], &patterns, 10).unwrap();
314        let result = secure_fs.resolve("secret.key");
315        assert!(matches!(result, Err(SecurityError::DeniedPath(_))));
316    }
317
318    #[test]
319    fn test_allowed_paths() {
320        let dir1 = tempdir().unwrap();
321        let dir2 = tempdir().unwrap();
322        let root1 = std::fs::canonicalize(dir1.path()).unwrap();
323        let root2 = std::fs::canonicalize(dir2.path()).unwrap();
324        fs::write(root2.join("file.txt"), "content").unwrap();
325
326        let secure_fs = SecureFs::new(&root1, vec![root2.clone()], &[], 10).unwrap();
327        assert!(secure_fs.is_within(&root2.join("file.txt")));
328    }
329}