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: 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        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    pub fn permissive() -> Self {
67        let root_fd = std::fs::File::open("/").unwrap();
68        Self {
69            root_fd: Arc::new(root_fd.into()),
70            root_path: PathBuf::from("/"),
71            allowed_paths: Vec::new(),
72            denied_patterns: Vec::new(),
73            max_symlink_depth: 255,
74            permissive: true,
75        }
76    }
77
78    pub fn is_permissive(&self) -> bool {
79        self.permissive
80    }
81
82    pub fn root(&self) -> &Path {
83        &self.root_path
84    }
85
86    pub fn resolve(&self, input_path: &str) -> Result<SafePath, SecurityError> {
87        if input_path.contains('\0') {
88            return Err(SecurityError::InvalidPath("null byte in path".into()));
89        }
90
91        if input_path.is_empty() {
92            return Err(SecurityError::InvalidPath("empty path".into()));
93        }
94
95        if self.permissive {
96            let resolved = if input_path.starts_with('/') {
97                PathBuf::from(input_path)
98            } else {
99                self.root_path.join(input_path)
100            };
101            let normalized = if resolved.exists() {
102                std::fs::canonicalize(&resolved)?
103            } else if let Some(parent) = resolved.parent() {
104                if parent.exists() {
105                    std::fs::canonicalize(parent)?.join(resolved.file_name().unwrap_or_default())
106                } else {
107                    normalize_path(&resolved)
108                }
109            } else {
110                normalize_path(&resolved)
111            };
112            return Ok(SafePath::unchecked(Arc::clone(&self.root_fd), normalized));
113        }
114
115        let relative = if input_path.starts_with('/') {
116            let input = PathBuf::from(input_path);
117            let normalized_input = if input.exists() {
118                std::fs::canonicalize(&input)?
119            } else if let Some(parent) = input.parent() {
120                if parent.exists() {
121                    std::fs::canonicalize(parent)?.join(input.file_name().unwrap_or_default())
122                } else {
123                    normalize_path(&input)
124                }
125            } else {
126                normalize_path(&input)
127            };
128
129            if normalized_input.starts_with(&self.root_path) {
130                normalized_input
131                    .strip_prefix(&self.root_path)
132                    .map(|p| p.to_path_buf())
133                    .unwrap_or_default()
134            } else {
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        let expected_path = self.root_path.join(&relative);
160        if self.is_path_denied(&expected_path) {
161            return Err(SecurityError::DeniedPath(expected_path));
162        }
163
164        let safe_path = SafePath::resolve(
165            Arc::clone(&self.root_fd),
166            self.root_path.clone(),
167            &relative,
168            self.max_symlink_depth,
169        )?;
170
171        let resolved = safe_path.as_path();
172        if !self.is_within(resolved) {
173            return Err(SecurityError::PathEscape(resolved.to_path_buf()));
174        }
175        if self.is_path_denied(resolved) {
176            return Err(SecurityError::DeniedPath(resolved.to_path_buf()));
177        }
178
179        Ok(safe_path)
180    }
181
182    pub fn resolve_with_limits(
183        &self,
184        input_path: &str,
185        limits: &ToolLimits,
186    ) -> Result<SafePath, SecurityError> {
187        let path = self.resolve(input_path)?;
188        let full_path = path.as_path();
189        let path_str = full_path.to_string_lossy();
190
191        if let Some(ref allowed) = limits.allowed_paths
192            && !allowed.is_empty()
193        {
194            let allowed_patterns: Vec<Pattern> = allowed
195                .iter()
196                .filter_map(|p| Pattern::new(p).ok())
197                .collect();
198            if !allowed_patterns.iter().any(|p| p.matches(&path_str)) {
199                return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
200            }
201        }
202
203        if let Some(ref denied) = limits.denied_paths {
204            let denied_patterns: Vec<Pattern> =
205                denied.iter().filter_map(|p| Pattern::new(p).ok()).collect();
206            if denied_patterns.iter().any(|p| p.matches(&path_str)) {
207                return Err(SecurityError::DeniedPath(full_path.to_path_buf()));
208            }
209        }
210
211        Ok(path)
212    }
213
214    pub fn open_read(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
215        let path = self.resolve(input_path)?;
216        SecureFileHandle::open_read(path)
217    }
218
219    pub fn open_write(&self, input_path: &str) -> Result<SecureFileHandle, SecurityError> {
220        let path = self.resolve(input_path)?;
221        SecureFileHandle::open_write(path)
222    }
223
224    pub fn is_within(&self, path: &Path) -> bool {
225        if self.permissive {
226            return true;
227        }
228
229        let canonical = self.resolve_to_canonical(path);
230        canonical.starts_with(&self.root_path)
231            || self.allowed_paths.iter().any(|p| canonical.starts_with(p))
232    }
233
234    fn resolve_to_canonical(&self, path: &Path) -> PathBuf {
235        if let Ok(p) = std::fs::canonicalize(path) {
236            return p;
237        }
238
239        let mut current = path.to_path_buf();
240        let mut components_to_append = Vec::new();
241
242        while let Some(parent) = current.parent() {
243            if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
244                let mut result = canonical_parent;
245                if let Some(name) = current.file_name() {
246                    result = result.join(name);
247                }
248                for component in components_to_append.into_iter().rev() {
249                    result = result.join(component);
250                }
251                return result;
252            }
253            if let Some(name) = current.file_name() {
254                components_to_append.push(name.to_os_string());
255            }
256            current = parent.to_path_buf();
257        }
258
259        normalize_path(path)
260    }
261
262    fn is_path_denied(&self, path: &Path) -> bool {
263        let path_str = path.to_string_lossy();
264        self.denied_patterns
265            .iter()
266            .any(|pattern| pattern.matches(&path_str))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use std::fs;
274    use tempfile::tempdir;
275
276    #[test]
277    fn test_secure_fs_new() {
278        let dir = tempdir().unwrap();
279        let fs = SecureFs::new(dir.path().to_path_buf(), vec![], vec![], 10).unwrap();
280        assert_eq!(fs.root(), std::fs::canonicalize(dir.path()).unwrap());
281    }
282
283    #[test]
284    fn test_resolve_valid_path() {
285        let dir = tempdir().unwrap();
286        let root = std::fs::canonicalize(dir.path()).unwrap();
287        fs::write(root.join("test.txt"), "content").unwrap();
288
289        let secure_fs = SecureFs::new(root.clone(), vec![], vec![], 10).unwrap();
290        let path = secure_fs.resolve("test.txt").unwrap();
291        assert_eq!(path.as_path(), root.join("test.txt"));
292    }
293
294    #[test]
295    fn test_resolve_path_escape_blocked() {
296        let dir = tempdir().unwrap();
297        let secure_fs = SecureFs::new(dir.path().to_path_buf(), vec![], vec![], 10).unwrap();
298        let result = secure_fs.resolve("../../../etc/passwd");
299        assert!(matches!(result, Err(SecurityError::PathEscape(_))));
300    }
301
302    #[test]
303    fn test_denied_patterns() {
304        let dir = tempdir().unwrap();
305        let root = std::fs::canonicalize(dir.path()).unwrap();
306        fs::write(root.join("secret.key"), "secret").unwrap();
307
308        let secure_fs = SecureFs::new(root, vec![], vec!["*.key".into()], 10).unwrap();
309        let result = secure_fs.resolve("secret.key");
310        assert!(matches!(result, Err(SecurityError::DeniedPath(_))));
311    }
312
313    #[test]
314    fn test_allowed_paths() {
315        let dir1 = tempdir().unwrap();
316        let dir2 = tempdir().unwrap();
317        let root1 = std::fs::canonicalize(dir1.path()).unwrap();
318        let root2 = std::fs::canonicalize(dir2.path()).unwrap();
319        fs::write(root2.join("file.txt"), "content").unwrap();
320
321        let secure_fs = SecureFs::new(root1, vec![root2.clone()], vec![], 10).unwrap();
322        assert!(secure_fs.is_within(&root2.join("file.txt")));
323    }
324}