claude_agent/security/path/
resolver.rs1use std::ffi::{CString, OsStr, OsString};
4use std::os::unix::ffi::OsStrExt;
5use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Arc;
8
9use rustix::fs::{Mode, OFlags, openat};
10use rustix::io::Errno;
11
12use crate::security::SecurityError;
13
14#[derive(Debug)]
15pub struct SafePath {
16 root_fd: Arc<OwnedFd>,
17 root_path: PathBuf,
18 components: Vec<OsString>,
19 resolved_path: PathBuf,
20 permissive: bool,
21}
22
23impl SafePath {
24 pub fn resolve(
25 root_fd: Arc<OwnedFd>,
26 root_path: PathBuf,
27 relative_path: &Path,
28 max_symlink_depth: u8,
29 ) -> Result<Self, SecurityError> {
30 let mut components = Vec::new();
31 let mut symlink_depth = 0u8;
32
33 for component in relative_path.components() {
34 match component {
35 Component::ParentDir => {
36 if components.is_empty() {
37 return Err(SecurityError::PathEscape(relative_path.to_path_buf()));
38 }
39 components.pop();
40 }
41 Component::CurDir | Component::RootDir => {}
42 Component::Normal(name) => {
43 components.push(name.to_os_string());
44 }
45 Component::Prefix(_) => {}
46 }
47 }
48
49 let mut validated_components = Vec::new();
50 let mut current_fd: BorrowedFd<'_> = root_fd.as_fd();
51 let mut owned_fds: Vec<OwnedFd> = Vec::new();
52
53 for (i, component) in components.iter().enumerate() {
54 let is_last = i == components.len() - 1;
55
56 let c_name = CString::new(component.as_bytes())
57 .map_err(|_| SecurityError::InvalidPath("null byte in path".into()))?;
58
59 let flags = if is_last {
60 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC
61 } else {
62 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
63 };
64
65 match openat(current_fd, &c_name, flags, Mode::empty()) {
66 Ok(fd) => {
67 validated_components.push(component.clone());
68 if !is_last {
69 let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
71 std::mem::forget(fd);
72 owned_fds.push(std_fd);
73 current_fd = owned_fds
75 .last()
76 .expect("owned_fds is non-empty after push")
77 .as_fd();
78 } else {
79 std::mem::forget(fd);
80 }
81 }
82 Err(Errno::LOOP) | Err(Errno::MLINK) => {
83 symlink_depth += 1;
84 if symlink_depth > max_symlink_depth {
85 return Err(SecurityError::SymlinkDepthExceeded {
86 path: relative_path.to_path_buf(),
87 max: max_symlink_depth,
88 });
89 }
90
91 let target = rustix::fs::readlinkat(current_fd, &c_name, vec![0u8; 4096])
92 .map_err(|e| {
93 SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error()))
94 })?;
95
96 let target_path = PathBuf::from(OsStr::from_bytes(target.to_bytes()));
97 if target_path.is_absolute() {
98 if !target_path.starts_with(&root_path) {
99 return Err(SecurityError::AbsoluteSymlink(target_path));
100 }
101 let relative = target_path
102 .strip_prefix(&root_path)
103 .expect("path verified with starts_with");
104 return Self::resolve(
105 Arc::clone(&root_fd),
106 root_path,
107 relative,
108 max_symlink_depth - symlink_depth,
109 );
110 }
111
112 let mut remaining: Vec<OsString> = target_path
113 .components()
114 .filter_map(|c| match c {
115 Component::Normal(s) => Some(s.to_os_string()),
116 _ => None,
117 })
118 .collect();
119
120 remaining.extend(components.iter().skip(i + 1).cloned());
121
122 let new_path: PathBuf = remaining.iter().collect();
123 let current_path: PathBuf = validated_components.iter().collect();
124 let full_path = current_path.join(&new_path);
125
126 return Self::resolve(
127 Arc::clone(&root_fd),
128 root_path,
129 &full_path,
130 max_symlink_depth - symlink_depth,
131 );
132 }
133 Err(Errno::NOENT) => {
134 validated_components.push(component.clone());
135 validated_components.extend(components.iter().skip(i + 1).cloned());
136 break;
137 }
138 Err(e) => {
139 return Err(SecurityError::Io(std::io::Error::from_raw_os_error(
140 e.raw_os_error(),
141 )));
142 }
143 }
144 }
145
146 let resolved_path = root_path.join(validated_components.iter().collect::<PathBuf>());
147
148 Ok(Self {
149 root_fd,
150 root_path,
151 components: validated_components,
152 resolved_path,
153 permissive: false,
154 })
155 }
156
157 pub fn unchecked(root_fd: Arc<OwnedFd>, resolved_path: PathBuf) -> Self {
160 let root_path = PathBuf::from("/");
161 let components = resolved_path
162 .strip_prefix("/")
163 .unwrap_or(&resolved_path)
164 .components()
165 .filter_map(|c| match c {
166 Component::Normal(s) => Some(s.to_os_string()),
167 _ => None,
168 })
169 .collect();
170
171 Self {
172 root_fd,
173 root_path,
174 components,
175 resolved_path,
176 permissive: true,
177 }
178 }
179
180 pub fn is_permissive(&self) -> bool {
181 self.permissive
182 }
183
184 pub fn root_fd(&self) -> BorrowedFd<'_> {
185 self.root_fd.as_fd()
186 }
187
188 pub fn root_path(&self) -> &Path {
189 &self.root_path
190 }
191
192 pub fn components(&self) -> &[OsString] {
193 &self.components
194 }
195
196 pub fn as_path(&self) -> &Path {
197 &self.resolved_path
198 }
199
200 pub fn filename(&self) -> Option<&OsStr> {
201 self.components.last().map(|s| s.as_os_str())
202 }
203
204 pub fn parent_components(&self) -> &[OsString] {
205 if self.components.is_empty() {
206 &[]
207 } else {
208 &self.components[..self.components.len() - 1]
209 }
210 }
211
212 pub fn open(&self, flags: OFlags) -> Result<OwnedFd, SecurityError> {
213 if self.permissive {
215 use std::fs::OpenOptions;
216 use std::os::unix::fs::OpenOptionsExt;
217
218 let mut opts = OpenOptions::new();
219
220 if flags.contains(OFlags::RDONLY) && !flags.contains(OFlags::WRONLY) {
221 opts.read(true);
222 }
223 if flags.contains(OFlags::WRONLY) || flags.contains(OFlags::RDWR) {
224 opts.write(true);
225 }
226 if flags.contains(OFlags::RDWR) {
227 opts.read(true);
228 }
229 if flags.contains(OFlags::CREATE) {
230 opts.create(true);
231 }
232 if flags.contains(OFlags::TRUNC) {
233 opts.truncate(true);
234 }
235 if flags.contains(OFlags::APPEND) {
236 opts.append(true);
237 }
238
239 opts.mode(0o644);
240
241 let file = opts.open(&self.resolved_path).map_err(SecurityError::Io)?;
242 return Ok(file.into());
243 }
244
245 if self.components.is_empty() {
246 let fd = rustix::fs::openat(
247 self.root_fd.as_fd(),
248 c".",
249 flags | OFlags::CLOEXEC,
250 Mode::empty(),
251 )
252 .map_err(|e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())))?;
253 return Ok(unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) });
255 }
256
257 let mut current_fd: BorrowedFd<'_> = self.root_fd.as_fd();
258 let mut owned_fds: Vec<OwnedFd> = Vec::new();
259
260 for (i, component) in self.components.iter().enumerate() {
261 let is_last = i == self.components.len() - 1;
262 let c_name = CString::new(component.as_bytes())
263 .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
264
265 let open_flags = if is_last {
266 flags | OFlags::NOFOLLOW | OFlags::CLOEXEC
267 } else {
268 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC
269 };
270
271 let fd = openat(current_fd, &c_name, open_flags, Mode::from_raw_mode(0o644)).map_err(
272 |e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())),
273 )?;
274
275 if is_last {
276 let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
278 std::mem::forget(fd);
279 return Ok(std_fd);
280 }
281
282 let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
284 std::mem::forget(fd);
285 owned_fds.push(std_fd);
286 current_fd = owned_fds
288 .last()
289 .expect("owned_fds is non-empty after push")
290 .as_fd();
291 }
292
293 unreachable!("loop always returns on is_last")
294 }
295
296 pub fn create_parent_dirs(&self) -> Result<(), SecurityError> {
297 if self.components.len() <= 1 {
298 return Ok(());
299 }
300
301 if self.permissive {
303 if let Some(parent) = self.resolved_path.parent() {
304 std::fs::create_dir_all(parent)?;
305 }
306 return Ok(());
307 }
308
309 let mut current_fd: BorrowedFd<'_> = self.root_fd.as_fd();
310 let mut owned_fds: Vec<OwnedFd> = Vec::new();
311
312 for component in self.parent_components() {
313 let c_name = CString::new(component.as_bytes())
314 .map_err(|_| SecurityError::InvalidPath("null byte".into()))?;
315
316 match openat(
317 current_fd,
318 &c_name,
319 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
320 Mode::empty(),
321 ) {
322 Ok(fd) => {
323 let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
325 std::mem::forget(fd);
326 owned_fds.push(std_fd);
327 current_fd = owned_fds
329 .last()
330 .expect("owned_fds is non-empty after push")
331 .as_fd();
332 }
333 Err(Errno::NOENT) => {
334 rustix::fs::mkdirat(current_fd, &c_name, Mode::from_raw_mode(0o755)).map_err(
335 |e| SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error())),
336 )?;
337
338 let fd = openat(
339 current_fd,
340 &c_name,
341 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
342 Mode::empty(),
343 )
344 .map_err(|e| {
345 SecurityError::Io(std::io::Error::from_raw_os_error(e.raw_os_error()))
346 })?;
347
348 let std_fd = unsafe { OwnedFd::from_raw_fd(fd.as_raw_fd()) };
350 std::mem::forget(fd);
351 owned_fds.push(std_fd);
352 current_fd = owned_fds
354 .last()
355 .expect("owned_fds is non-empty after push")
356 .as_fd();
357 }
358 Err(e) => {
359 return Err(SecurityError::Io(std::io::Error::from_raw_os_error(
360 e.raw_os_error(),
361 )));
362 }
363 }
364 }
365
366 Ok(())
367 }
368}
369
370impl Clone for SafePath {
371 fn clone(&self) -> Self {
372 Self {
373 root_fd: Arc::clone(&self.root_fd),
374 root_path: self.root_path.clone(),
375 components: self.components.clone(),
376 resolved_path: self.resolved_path.clone(),
377 permissive: self.permissive,
378 }
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::fs;
386 use tempfile::tempdir;
387
388 fn open_dir(path: &Path) -> Arc<OwnedFd> {
389 let fd = std::fs::File::open(path).unwrap();
390 Arc::new(fd.into())
391 }
392
393 #[test]
394 fn test_resolve_simple() {
395 let dir = tempdir().unwrap();
396 let root = std::fs::canonicalize(dir.path()).unwrap();
397 fs::write(root.join("test.txt"), "content").unwrap();
398
399 let root_fd = open_dir(&root);
400 let path = SafePath::resolve(root_fd, root.clone(), Path::new("test.txt"), 10).unwrap();
401
402 assert_eq!(path.as_path(), root.join("test.txt"));
403 }
404
405 #[test]
406 fn test_resolve_nonexistent() {
407 let dir = tempdir().unwrap();
408 let root = std::fs::canonicalize(dir.path()).unwrap();
409
410 let root_fd = open_dir(&root);
411 let path = SafePath::resolve(root_fd, root.clone(), Path::new("newfile.txt"), 10).unwrap();
412
413 assert_eq!(path.as_path(), root.join("newfile.txt"));
414 }
415
416 #[test]
417 fn test_path_traversal_blocked() {
418 let dir = tempdir().unwrap();
419 let root = std::fs::canonicalize(dir.path()).unwrap();
420
421 let root_fd = open_dir(&root);
422 let result = SafePath::resolve(root_fd, root, Path::new("../../../etc/passwd"), 10);
423
424 assert!(matches!(result, Err(SecurityError::PathEscape(_))));
425 }
426
427 #[test]
428 fn test_symlink_within_sandbox() {
429 let dir = tempdir().unwrap();
430 let root = std::fs::canonicalize(dir.path()).unwrap();
431
432 fs::write(root.join("target.txt"), "content").unwrap();
433 std::os::unix::fs::symlink("target.txt", root.join("link.txt")).unwrap();
434
435 let root_fd = open_dir(&root);
436 let path = SafePath::resolve(root_fd, root.clone(), Path::new("link.txt"), 10).unwrap();
437
438 assert_eq!(path.as_path(), root.join("target.txt"));
439 }
440
441 #[test]
442 fn test_symlink_depth_limit() {
443 let dir = tempdir().unwrap();
444 let root = std::fs::canonicalize(dir.path()).unwrap();
445
446 for i in 0..15 {
447 let target = if i == 14 {
448 "final.txt".to_string()
449 } else {
450 format!("link{}.txt", i + 1)
451 };
452 std::os::unix::fs::symlink(&target, root.join(format!("link{}.txt", i))).unwrap();
453 }
454 fs::write(root.join("final.txt"), "content").unwrap();
455
456 let root_fd = open_dir(&root);
457 let result = SafePath::resolve(root_fd, root, Path::new("link0.txt"), 10);
458
459 assert!(matches!(
460 result,
461 Err(SecurityError::SymlinkDepthExceeded { .. })
462 ));
463 }
464}