1use cap_std::ambient_authority;
8use cap_std::fs::Dir;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12#[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
25pub struct SandboxedFs {
27 workspace: PathBuf,
29 #[allow(dead_code)]
31 cap_dir: Dir,
32 shell_allowlist: HashSet<String>,
34 denied_patterns: Vec<String>,
36}
37
38impl SandboxedFs {
39 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 pub fn validate_path(&self, path: &Path) -> Result<PathBuf, SandboxError> {
56 let resolved = if path.is_absolute() {
58 path.to_path_buf()
59 } else {
60 self.workspace.join(path)
61 };
62
63 let canonical = match resolved.canonicalize() {
65 Ok(p) => p,
66 Err(_) => {
67 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 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 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 pub fn validate_command(&self, command: &str) -> Result<(), SandboxError> {
102 let base_cmd = command.split_whitespace().next().unwrap_or("").to_string();
104
105 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 pub fn allow_command(&mut self, command: &str) {
120 self.shell_allowlist.insert(command.to_string());
121 }
122
123 pub fn deny_command(&mut self, command: &str) {
125 self.shell_allowlist.remove(command);
126 }
127
128 pub fn add_denied_pattern(&mut self, pattern: &str) {
130 self.denied_patterns.push(pattern.to_string());
131 }
132
133 pub fn workspace(&self) -> &Path {
135 &self.workspace
136 }
137
138 pub fn allowlist(&self) -> &HashSet<String> {
140 &self.shell_allowlist
141 }
142
143 pub fn is_command_allowed(&self, command: &str) -> bool {
145 self.validate_command(command).is_ok()
146 }
147}
148
149fn default_shell_allowlist() -> HashSet<String> {
151 [
152 "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 "git",
169 "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 "sed",
187 "awk",
188 "cut",
189 "tr",
190 "jq",
191 "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
210fn 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 assert!(sandbox.validate_command("/usr/bin/git status").is_ok());
343 }
344}