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<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 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 let relative = if input_path.starts_with('/') {
114 let input = PathBuf::from(input_path);
115 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 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);
161 if self.is_path_denied(&expected_path) {
162 return Err(SecurityError::DeniedPath(expected_path));
163 }
164
165 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 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}