claude_agent/security/fs/
mod.rs1mod 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 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}