Skip to main content

agnt_tools/
sandbox.rs

1//! Filesystem root sandbox for agnt-tools.
2//!
3//! Resolves user-supplied paths against a fixed root directory, rejecting any
4//! path that escapes the root via `..`, absolute-path rewrites, or symlink
5//! traversal. This is the primary boundary between a hostile LLM's tool calls
6//! and the host filesystem.
7//!
8//! ## Threat model
9//!
10//! - Adversary: LLM output that issues `read_file {"path":"/etc/shadow"}`-class
11//!   tool calls.
12//! - Defense: every filesystem-touching tool holds an `Arc<FilesystemRoot>`
13//!   (when sandboxed) and routes input paths through [`FilesystemRoot::resolve`]
14//!   before touching `std::fs`.
15//! - Non-goal: defending against OS-level privilege escalation; this is a
16//!   path-safety layer, not a chroot.
17//!
18//! Constructing a [`FilesystemRoot`] canonicalizes the root and stores it; all
19//! subsequent resolutions are compared against that canonical prefix.
20
21use std::path::{Component, Path, PathBuf};
22
23/// A canonicalized sandbox root. All paths resolved through this instance are
24/// guaranteed to live under the root directory on the local filesystem.
25#[derive(Debug, Clone)]
26pub struct FilesystemRoot {
27    root: PathBuf,
28}
29
30impl FilesystemRoot {
31    /// Construct a sandbox rooted at `root`. The directory must exist and be
32    /// canonicalizable (symlinks resolved). Returns `Err` if the root does not
33    /// exist or is not accessible.
34    pub fn new(root: impl Into<PathBuf>) -> Result<Self, String> {
35        let root = root.into();
36        let canonical = std::fs::canonicalize(&root)
37            .map_err(|e| format!("sandbox root {}: {}", root.display(), e))?;
38        Ok(Self { root: canonical })
39    }
40
41    /// Return the canonical root directory.
42    pub fn root(&self) -> &Path {
43        &self.root
44    }
45
46    /// Resolve a user-supplied path against the sandbox root.
47    ///
48    /// Returns the absolute, canonical path if it is (or — for not-yet-existing
49    /// paths — would be) under the root. Rejects:
50    ///
51    /// - any path whose components contain `..`
52    /// - any path that canonicalizes outside the root (symlink escape)
53    /// - for new files: any parent path that escapes the root
54    pub fn resolve(&self, input: &str) -> Result<PathBuf, String> {
55        if input.is_empty() {
56            return Err("empty path".into());
57        }
58        let raw = Path::new(input);
59
60        // Reject explicit `..` components before touching the filesystem — this
61        // avoids relying on canonicalize() alone (which can still be tricked by
62        // symlink chains that happen to land back under the root).
63        for comp in raw.components() {
64            if matches!(comp, Component::ParentDir) {
65                return Err(format!("path contains '..': {}", input));
66            }
67        }
68
69        // Join relative paths onto the root so `"foo.txt"` resolves to
70        // `<root>/foo.txt`. Absolute paths are taken as-is but will still be
71        // checked for containment below.
72        let joined: PathBuf = if raw.is_absolute() {
73            raw.to_path_buf()
74        } else {
75            self.root.join(raw)
76        };
77
78        // For existing paths, canonicalize directly.
79        if let Ok(canonical) = std::fs::canonicalize(&joined) {
80            if !canonical.starts_with(&self.root) {
81                return Err(format!(
82                    "path escapes sandbox root: {} not under {}",
83                    canonical.display(),
84                    self.root.display()
85                ));
86            }
87            return Ok(canonical);
88        }
89
90        // For paths that don't exist yet (e.g. new file in WriteFile),
91        // canonicalize the parent and rejoin the filename.
92        let parent = joined
93            .parent()
94            .ok_or_else(|| format!("path has no parent: {}", input))?;
95        let file_name = joined
96            .file_name()
97            .ok_or_else(|| format!("path has no filename: {}", input))?;
98
99        let parent_canonical = std::fs::canonicalize(parent).map_err(|e| {
100            format!(
101                "sandbox parent {} of {}: {}",
102                parent.display(),
103                input,
104                e
105            )
106        })?;
107
108        if !parent_canonical.starts_with(&self.root) {
109            return Err(format!(
110                "path escapes sandbox root: {} not under {}",
111                parent_canonical.display(),
112                self.root.display()
113            ));
114        }
115
116        Ok(parent_canonical.join(file_name))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::fs;
124
125    fn tmpdir() -> PathBuf {
126        let d = std::env::temp_dir().join(format!(
127            "agnt-sandbox-{}-{}",
128            std::process::id(),
129            std::time::SystemTime::now()
130                .duration_since(std::time::UNIX_EPOCH)
131                .map(|d| d.as_nanos())
132                .unwrap_or(0)
133        ));
134        fs::create_dir_all(&d).unwrap();
135        d
136    }
137
138    #[test]
139    fn resolves_relative_under_root() {
140        let dir = tmpdir();
141        let sandbox = FilesystemRoot::new(&dir).unwrap();
142        fs::write(dir.join("a.txt"), "x").unwrap();
143        let resolved = sandbox.resolve("a.txt").unwrap();
144        assert!(resolved.starts_with(sandbox.root()));
145    }
146
147    #[test]
148    fn rejects_parent_escape() {
149        let dir = tmpdir();
150        let sandbox = FilesystemRoot::new(&dir).unwrap();
151        let err = sandbox.resolve("../etc/shadow").unwrap_err();
152        assert!(err.contains(".."), "expected .. rejection, got {}", err);
153    }
154
155    #[test]
156    fn rejects_absolute_outside_root() {
157        let dir = tmpdir();
158        let sandbox = FilesystemRoot::new(&dir).unwrap();
159        let err = sandbox.resolve("/etc/passwd").unwrap_err();
160        assert!(err.contains("sandbox") || err.contains("escape"));
161    }
162
163    #[test]
164    fn allows_new_file_under_root() {
165        let dir = tmpdir();
166        let sandbox = FilesystemRoot::new(&dir).unwrap();
167        let resolved = sandbox.resolve("new.txt").unwrap();
168        assert!(resolved.starts_with(sandbox.root()));
169    }
170
171    #[test]
172    fn rejects_symlink_escape() {
173        #[cfg(unix)]
174        {
175            let dir = tmpdir();
176            let outside = tmpdir();
177            fs::write(outside.join("secret.txt"), "pw").unwrap();
178            std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap();
179            let sandbox = FilesystemRoot::new(&dir).unwrap();
180            let err = sandbox.resolve("link/secret.txt").unwrap_err();
181            assert!(err.contains("escape") || err.contains("sandbox"));
182        }
183    }
184}