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<String>,
23    max_symlink_depth: u8,
24    permissive: bool,
25}
26
27impl SecureFs {
28    pub fn new(
29        root: PathBuf,
30        allowed_paths: Vec<PathBuf>,
31        denied_patterns: Vec<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        Ok(Self {
43            root_fd: Arc::new(root_fd.into()),
44            root_path,
45            allowed_paths: allowed_paths
46                .into_iter()
47                .filter_map(|p| {
48                    if p.exists() {
49                        std::fs::canonicalize(&p).ok()
50                    } else {
51                        Some(normalize_path(&p))
52                    }
53                })
54                .collect(),
55            denied_patterns,
56            max_symlink_depth,
57            permissive: false,
58        })
59    }
60
61    pub fn permissive() -> Self {
62        let root_fd = std::fs::File::open("/").unwrap();
63        Self {
64            root_fd: Arc::new(root_fd.into()),
65            root_path: PathBuf::from("/"),
66            allowed_paths: Vec::new(),
67            denied_patterns: Vec::new(),
68            max_symlink_depth: 255,
69            permissive: true,
70        }
71    }
72
73    pub fn is_permissive(&self) -> bool {
74        self.permissive
75    }
76
77    pub fn root(&self) -> &Path {
78        &self.root_path
79    }
80
81    pub fn resolve(&self, input_path: &str) -> Result<SafePath, SecurityError> {
82        if input_path.contains('\0') {
83            return Err(SecurityError::InvalidPath("null byte in path".into()));
84        }
85
86        if input_path.is_empty() {
87            return Err(SecurityError::InvalidPath("empty path".into()));
88        }
89
90        // Permissive mode: minimal validation, allow symlinks
91        if self.permissive {
92            let resolved = if input_path.starts_with('/') {
93                PathBuf::from(input_path)
94            } else {
95                self.root_path.join(input_path)
96            };
97            let normalized = if resolved.exists() {
98                std::fs::canonicalize(&resolved)?
99            } else if let Some(parent) = resolved.parent() {
100                if parent.exists() {
101                    std::fs::canonicalize(parent)?.join(resolved.file_name().unwrap_or_default())
102                } else {
103                    normalize_path(&resolved)
104                }
105            } else {
106                normalize_path(&resolved)
107            };
108            return Ok(SafePath::unchecked(Arc::clone(&self.root_fd), normalized));
109        }
110
111        // Secure mode: validate BEFORE filesystem operations (TOCTOU-safe)
112        // 1. Normalize input path to detect traversal attempts
113        let relative = if input_path.starts_with('/') {
114            let input = PathBuf::from(input_path);
115            // Canonicalize if exists to handle symlinks like /var -> /private/var on macOS
116            let normalized_input = if input.exists() {
117                std::fs::canonicalize(&input)?
118            } else if let Some(parent) = input.parent() {
119                if parent.exists() {
120                    std::fs::canonicalize(parent)?.join(input.file_name().unwrap_or_default())
121                } else {
122                    normalize_path(&input)
123                }
124            } else {
125                normalize_path(&input)
126            };
127
128            if normalized_input.starts_with(&self.root_path) {
129                normalized_input
130                    .strip_prefix(&self.root_path)
131                    .map(|p| p.to_path_buf())
132                    .unwrap_or_default()
133            } else {
134                // Check allowed_paths
135                let mut found = None;
136                for allowed in &self.allowed_paths {
137                    if normalized_input.starts_with(allowed) {
138                        found = Some(
139                            normalized_input
140                                .strip_prefix(allowed)
141                                .map(|p| p.to_path_buf())
142                                .unwrap_or_default(),
143                        );
144                        break;
145                    }
146                }
147                match found {
148                    Some(rel) => rel,
149                    None => return Err(SecurityError::PathEscape(normalized_input)),
150                }
151            }
152        } else {
153            normalize_path(&PathBuf::from(input_path))
154                .strip_prefix("/")
155                .map(|p| p.to_path_buf())
156                .unwrap_or_else(|_| PathBuf::from(input_path))
157        };
158
159        // 2. Check denied patterns on the relative path
160        let expected_path = self.root_path.join(&relative);
161        if self.is_path_denied(&expected_path) {
162            return Err(SecurityError::DeniedPath(expected_path));
163        }
164
165        // 3. Use TOCTOU-safe resolution (openat + O_NOFOLLOW)
166        let safe_path = SafePath::resolve(
167            Arc::clone(&self.root_fd),
168            self.root_path.clone(),
169            &relative,
170            self.max_symlink_depth,
171        )?;
172
173        // 4. Final validation on resolved path
174        let resolved = safe_path.as_path();
175        if !self.is_path_allowed(resolved) {
176            return Err(SecurityError::PathEscape(resolved.to_path_buf()));
177        }
178        if self.is_path_denied(resolved) {
179            return Err(SecurityError::DeniedPath(resolved.to_path_buf()));
180        }
181
182        Ok(safe_path)
183    }
184
185    pub fn resolve_with_limits(
186        &self,
187        input_path: &str,
188        limits: &ToolLimits,
189    ) -> Result<SafePath, SecurityError> {
190        let path = self.resolve(input_path)?;
191        let full_path = path.as_path();
192
193        if let Some(ref allowed) = limits.allowed_paths
194            && !allowed.is_empty()
195            && !self.matches_any_pattern(full_path, allowed)
196        {
197            return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
198        }
199
200        if let Some(ref denied) = limits.denied_paths
201            && self.matches_any_pattern(full_path, denied)
202        {
203            return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
204        }
205
206        Ok(path)
207    }
208
209    pub fn open_read(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
210        let path = self.resolve(input_path)?;
211        SecureFileHandle::open_read(path)
212    }
213
214    pub fn open_write(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
215        let path = self.resolve(input_path)?;
216        SecureFileHandle::open_write(path)
217    }
218
219    pub fn is_within(&self, path: &Path) -> bool {
220        if path.starts_with(&self.root_path) {
221            return true;
222        }
223        self.allowed_paths.iter().any(|p| path.starts_with(p))
224    }
225
226    fn is_path_allowed(&self, path: &Path) -> bool {
227        if path.starts_with(&self.root_path) {
228            return true;
229        }
230        self.allowed_paths.iter().any(|p| path.starts_with(p))
231    }
232
233    fn is_path_denied(&self, path: &Path) -> bool {
234        let path_str = path.to_string_lossy();
235        self.denied_patterns.iter().any(|pattern| {
236            Pattern::new(pattern)
237                .map(|g| g.matches(&path_str))
238                .unwrap_or(false)
239        })
240    }
241
242    fn matches_any_pattern(&self, path: &Path, patterns: &[String]) -> bool {
243        let path_str = path.to_string_lossy();
244        patterns.iter().any(|pattern| {
245            Pattern::new(pattern)
246                .map(|g| g.matches(&path_str))
247                .unwrap_or_else(|_| pattern == path_str.as_ref())
248        })
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::fs;
256    use tempfile::tempdir;
257
258    #[test]
259    fn test_secure_fs_new() {
260        let dir = tempdir().unwrap();
261        let fs = SecureFs::new(dir.path().to_path_buf(), vec![], vec![], 10).unwrap();
262        assert_eq!(fs.root(), std::fs::canonicalize(dir.path()).unwrap());
263    }
264
265    #[test]
266    fn test_resolve_valid_path() {
267        let dir = tempdir().unwrap();
268        let root = std::fs::canonicalize(dir.path()).unwrap();
269        fs::write(root.join("test.txt"), "content").unwrap();
270
271        let secure_fs = SecureFs::new(root.clone(), vec![], vec![], 10).unwrap();
272        let path = secure_fs.resolve("test.txt").unwrap();
273        assert_eq!(path.as_path(), root.join("test.txt"));
274    }
275
276    #[test]
277    fn test_resolve_path_escape_blocked() {
278        let dir = tempdir().unwrap();
279        let secure_fs = SecureFs::new(dir.path().to_path_buf(), vec![], vec![], 10).unwrap();
280        let result = secure_fs.resolve("../../../etc/passwd");
281        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
282    }
283
284    #[test]
285    fn test_denied_patterns() {
286        let dir = tempdir().unwrap();
287        let root = std::fs::canonicalize(dir.path()).unwrap();
288        fs::write(root.join("secret.key"), "secret").unwrap();
289
290        let secure_fs = SecureFs::new(root, vec![], vec!["*.key".into()], 10).unwrap();
291        let result = secure_fs.resolve("secret.key");
292        assert!(matches!(result, Err(SecurityError::DeniedPath(_))));
293    }
294
295    #[test]
296    fn test_allowed_paths() {
297        let dir1 = tempdir().unwrap();
298        let dir2 = tempdir().unwrap();
299        let root1 = std::fs::canonicalize(dir1.path()).unwrap();
300        let root2 = std::fs::canonicalize(dir2.path()).unwrap();
301        fs::write(root2.join("file.txt"), "content").unwrap();
302
303        let secure_fs = SecureFs::new(root1, vec![root2.clone()], vec![], 10).unwrap();
304        assert!(secure_fs.is_within(&root2.join("file.txt")));
305    }
306}