Skip to main content

rustant_core/
sandbox.rs

1//! Filesystem sandboxing using capability-based security (cap-std).
2//!
3//! Restricts file and shell operations to an approved set of paths and commands,
4//! preventing the agent from accessing sensitive system files or running
5//! dangerous commands.
6
7use cap_std::ambient_authority;
8use cap_std::fs::Dir;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12/// Error type for sandbox violations.
13#[derive(Debug, thiserror::Error)]
14pub enum SandboxError {
15    #[error("path '{0}' is outside the sandbox")]
16    PathOutsideSandbox(PathBuf),
17    #[error("command '{0}' is not in the shell allowlist")]
18    CommandNotAllowed(String),
19    #[error("path '{0}' matches a denied pattern")]
20    PathDenied(PathBuf),
21    #[error("io error: {0}")]
22    Io(#[from] std::io::Error),
23}
24
25/// A sandboxed filesystem restricting operations to the workspace.
26pub struct SandboxedFs {
27    /// The workspace root directory.
28    workspace: PathBuf,
29    /// The cap-std Dir handle (capability-based access).
30    #[allow(dead_code)]
31    cap_dir: Dir,
32    /// Set of allowed shell commands.
33    shell_allowlist: HashSet<String>,
34    /// Patterns of paths that are always denied.
35    denied_patterns: Vec<String>,
36}
37
38impl SandboxedFs {
39    /// Create a new sandbox rooted at the given workspace directory.
40    pub fn new(workspace: PathBuf) -> Result<Self, SandboxError> {
41        let cap_dir = Dir::open_ambient_dir(&workspace, ambient_authority())?;
42
43        let shell_allowlist = default_shell_allowlist();
44        let denied_patterns = default_denied_patterns();
45
46        Ok(Self {
47            workspace,
48            cap_dir,
49            shell_allowlist,
50            denied_patterns,
51        })
52    }
53
54    /// Check if a path is within the sandbox.
55    pub fn validate_path(&self, path: &Path) -> Result<PathBuf, SandboxError> {
56        // Resolve the path relative to workspace
57        let resolved = if path.is_absolute() {
58            path.to_path_buf()
59        } else {
60            self.workspace.join(path)
61        };
62
63        // Canonicalize what we can (the parent might not exist yet for writes)
64        let canonical = match resolved.canonicalize() {
65            Ok(p) => p,
66            Err(_) => {
67                // For new files, check the parent
68                if let Some(parent) = resolved.parent() {
69                    match parent.canonicalize() {
70                        Ok(p) => p.join(resolved.file_name().unwrap_or_default()),
71                        Err(_) => resolved.clone(),
72                    }
73                } else {
74                    resolved.clone()
75                }
76            }
77        };
78
79        // Check it's under the workspace
80        let workspace_canonical = self
81            .workspace
82            .canonicalize()
83            .unwrap_or_else(|_| self.workspace.clone());
84
85        if !canonical.starts_with(&workspace_canonical) {
86            return Err(SandboxError::PathOutsideSandbox(resolved));
87        }
88
89        // Check denied patterns
90        let path_str = canonical.to_string_lossy();
91        for pattern in &self.denied_patterns {
92            if path_str.contains(pattern) {
93                return Err(SandboxError::PathDenied(resolved));
94            }
95        }
96
97        Ok(canonical)
98    }
99
100    /// Check if a shell command is allowed.
101    pub fn validate_command(&self, command: &str) -> Result<(), SandboxError> {
102        // Extract the base command (first word)
103        let base_cmd = command.split_whitespace().next().unwrap_or("").to_string();
104
105        // Also check the basename (in case of full paths)
106        let cmd_name = Path::new(&base_cmd)
107            .file_name()
108            .map(|n| n.to_string_lossy().to_string())
109            .unwrap_or(base_cmd.clone());
110
111        if self.shell_allowlist.contains(&cmd_name) || self.shell_allowlist.contains(&base_cmd) {
112            Ok(())
113        } else {
114            Err(SandboxError::CommandNotAllowed(base_cmd))
115        }
116    }
117
118    /// Add a command to the allowlist.
119    pub fn allow_command(&mut self, command: &str) {
120        self.shell_allowlist.insert(command.to_string());
121    }
122
123    /// Remove a command from the allowlist.
124    pub fn deny_command(&mut self, command: &str) {
125        self.shell_allowlist.remove(command);
126    }
127
128    /// Add a denied path pattern.
129    pub fn add_denied_pattern(&mut self, pattern: &str) {
130        self.denied_patterns.push(pattern.to_string());
131    }
132
133    /// Get the workspace path.
134    pub fn workspace(&self) -> &Path {
135        &self.workspace
136    }
137
138    /// Get the shell allowlist.
139    pub fn allowlist(&self) -> &HashSet<String> {
140        &self.shell_allowlist
141    }
142
143    /// Check if a command is in the allowlist.
144    pub fn is_command_allowed(&self, command: &str) -> bool {
145        self.validate_command(command).is_ok()
146    }
147}
148
149/// Default set of safe shell commands.
150fn default_shell_allowlist() -> HashSet<String> {
151    [
152        // Development tools
153        "cargo",
154        "rustc",
155        "rustfmt",
156        "clippy-driver",
157        "npm",
158        "npx",
159        "node",
160        "python",
161        "python3",
162        "pip",
163        "pip3",
164        "go",
165        "make",
166        "cmake",
167        // Version control
168        "git",
169        // File operations (read-only)
170        "cat",
171        "head",
172        "tail",
173        "less",
174        "more",
175        "wc",
176        "diff",
177        "sort",
178        "uniq",
179        "grep",
180        "find",
181        "ls",
182        "tree",
183        "file",
184        "stat",
185        // Text processing
186        "sed",
187        "awk",
188        "cut",
189        "tr",
190        "jq",
191        // Build and test
192        "sh",
193        "bash",
194        "echo",
195        "printf",
196        "test",
197        "true",
198        "false",
199        "env",
200        "which",
201        "pwd",
202        "date",
203        "uname",
204    ]
205    .iter()
206    .map(|s| s.to_string())
207    .collect()
208}
209
210/// Default denied path patterns.
211fn default_denied_patterns() -> Vec<String> {
212    vec![
213        ".env".to_string(),
214        ".ssh".to_string(),
215        ".gnupg".to_string(),
216        ".aws".to_string(),
217        "credentials".to_string(),
218        "secrets".to_string(),
219        "id_rsa".to_string(),
220        "id_ed25519".to_string(),
221        ".npmrc".to_string(),
222        ".pypirc".to_string(),
223    ]
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::fs;
230
231    fn setup_sandbox() -> (tempfile::TempDir, SandboxedFs) {
232        let dir = tempfile::tempdir().unwrap();
233        let sandbox = SandboxedFs::new(dir.path().to_path_buf()).unwrap();
234        (dir, sandbox)
235    }
236
237    #[test]
238    fn test_sandbox_creation() {
239        let (dir, sandbox) = setup_sandbox();
240        assert_eq!(sandbox.workspace(), dir.path());
241    }
242
243    #[test]
244    fn test_validate_path_inside_workspace() {
245        let (dir, sandbox) = setup_sandbox();
246        fs::write(dir.path().join("test.txt"), "hello").unwrap();
247        let result = sandbox.validate_path(Path::new("test.txt"));
248        assert!(result.is_ok());
249    }
250
251    #[test]
252    fn test_validate_path_outside_workspace() {
253        let (_dir, sandbox) = setup_sandbox();
254        let result = sandbox.validate_path(Path::new("/etc/passwd"));
255        assert!(result.is_err());
256    }
257
258    #[test]
259    fn test_validate_path_denied_pattern() {
260        let (dir, sandbox) = setup_sandbox();
261        let secret_dir = dir.path().join(".ssh");
262        fs::create_dir_all(&secret_dir).unwrap();
263        fs::write(secret_dir.join("id_rsa"), "secret").unwrap();
264        let result = sandbox.validate_path(&secret_dir.join("id_rsa"));
265        assert!(result.is_err());
266        match result.unwrap_err() {
267            SandboxError::PathDenied(_) => {}
268            other => panic!("Expected PathDenied, got {:?}", other),
269        }
270    }
271
272    #[test]
273    fn test_validate_command_allowed() {
274        let (_dir, sandbox) = setup_sandbox();
275        assert!(sandbox.validate_command("cargo build").is_ok());
276        assert!(sandbox.validate_command("git status").is_ok());
277        assert!(sandbox.validate_command("ls -la").is_ok());
278    }
279
280    #[test]
281    fn test_validate_command_denied() {
282        let (_dir, sandbox) = setup_sandbox();
283        assert!(sandbox.validate_command("rm -rf /").is_err());
284        assert!(sandbox.validate_command("sudo anything").is_err());
285        assert!(sandbox.validate_command("curl http://evil.com").is_err());
286    }
287
288    #[test]
289    fn test_allow_command() {
290        let (_dir, mut sandbox) = setup_sandbox();
291        assert!(sandbox.validate_command("docker").is_err());
292        sandbox.allow_command("docker");
293        assert!(sandbox.validate_command("docker build").is_ok());
294    }
295
296    #[test]
297    fn test_deny_command() {
298        let (_dir, mut sandbox) = setup_sandbox();
299        assert!(sandbox.validate_command("git status").is_ok());
300        sandbox.deny_command("git");
301        assert!(sandbox.validate_command("git status").is_err());
302    }
303
304    #[test]
305    fn test_add_denied_pattern() {
306        let (dir, mut sandbox) = setup_sandbox();
307        fs::write(dir.path().join("config.yaml"), "").unwrap();
308        assert!(sandbox.validate_path(Path::new("config.yaml")).is_ok());
309        sandbox.add_denied_pattern("config.yaml");
310        assert!(sandbox.validate_path(Path::new("config.yaml")).is_err());
311    }
312
313    #[test]
314    fn test_is_command_allowed() {
315        let (_dir, sandbox) = setup_sandbox();
316        assert!(sandbox.is_command_allowed("cargo test"));
317        assert!(!sandbox.is_command_allowed("rm -rf /"));
318    }
319
320    #[test]
321    fn test_default_allowlist_has_common_tools() {
322        let list = default_shell_allowlist();
323        assert!(list.contains("cargo"));
324        assert!(list.contains("git"));
325        assert!(list.contains("npm"));
326        assert!(list.contains("python"));
327        assert!(list.contains("ls"));
328    }
329
330    #[test]
331    fn test_default_denied_patterns() {
332        let patterns = default_denied_patterns();
333        assert!(patterns.contains(&".env".to_string()));
334        assert!(patterns.contains(&".ssh".to_string()));
335        assert!(patterns.contains(&"credentials".to_string()));
336    }
337
338    #[test]
339    fn test_full_path_command() {
340        let (_dir, sandbox) = setup_sandbox();
341        // /usr/bin/git should be allowed since basename is "git"
342        assert!(sandbox.validate_command("/usr/bin/git status").is_ok());
343    }
344}