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: 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}