1use std::fmt;
2use std::path::{Component, PathBuf};
3
4use crate::error::SemaError;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Caps(u64);
8
9impl Caps {
10 pub const NONE: Caps = Caps(0);
11 pub const FS_READ: Caps = Caps(1 << 0);
12 pub const FS_WRITE: Caps = Caps(1 << 1);
13 pub const SHELL: Caps = Caps(1 << 2);
14 pub const NETWORK: Caps = Caps(1 << 3);
15 pub const ENV_READ: Caps = Caps(1 << 4);
16 pub const ENV_WRITE: Caps = Caps(1 << 5);
17 pub const PROCESS: Caps = Caps(1 << 6);
18 pub const LLM: Caps = Caps(1 << 7);
19 pub const SERIAL: Caps = Caps(1 << 8);
20
21 pub const ALL: Caps = Caps(
22 Self::FS_READ.0
23 | Self::FS_WRITE.0
24 | Self::SHELL.0
25 | Self::NETWORK.0
26 | Self::ENV_READ.0
27 | Self::ENV_WRITE.0
28 | Self::PROCESS.0
29 | Self::LLM.0
30 | Self::SERIAL.0,
31 );
32
33 pub const STRICT: Caps = Caps(
34 Self::SHELL.0
35 | Self::FS_WRITE.0
36 | Self::NETWORK.0
37 | Self::ENV_WRITE.0
38 | Self::PROCESS.0
39 | Self::LLM.0
40 | Self::SERIAL.0,
41 );
42
43 pub fn contains(self, other: Caps) -> bool {
44 self.0 & other.0 == other.0
45 }
46
47 pub fn union(self, other: Caps) -> Caps {
48 Caps(self.0 | other.0)
49 }
50
51 pub fn name(self) -> &'static str {
52 match self {
53 Caps::NONE => "none",
54 Caps::FS_READ => "fs-read",
55 Caps::FS_WRITE => "fs-write",
56 Caps::SHELL => "shell",
57 Caps::NETWORK => "network",
58 Caps::ENV_READ => "env-read",
59 Caps::ENV_WRITE => "env-write",
60 Caps::PROCESS => "process",
61 Caps::LLM => "llm",
62 Caps::SERIAL => "serial",
63 Caps::ALL => "all",
64 Caps::STRICT => "strict",
65 _ => "unknown",
66 }
67 }
68
69 pub fn from_name(s: &str) -> Option<Self> {
70 match s {
71 "none" => Some(Caps::NONE),
72 "fs-read" => Some(Caps::FS_READ),
73 "fs-write" => Some(Caps::FS_WRITE),
74 "shell" => Some(Caps::SHELL),
75 "network" => Some(Caps::NETWORK),
76 "env-read" => Some(Caps::ENV_READ),
77 "env-write" => Some(Caps::ENV_WRITE),
78 "process" => Some(Caps::PROCESS),
79 "llm" => Some(Caps::LLM),
80 "serial" => Some(Caps::SERIAL),
81 "all" => Some(Caps::ALL),
82 "strict" => Some(Caps::STRICT),
83 _ => None,
84 }
85 }
86}
87
88impl fmt::Display for Caps {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(self.name())
91 }
92}
93
94#[derive(Debug, Clone)]
95pub struct Sandbox {
96 pub denied: Caps,
97 allowed_paths: Option<Vec<PathBuf>>,
98}
99
100fn normalize_lexical(path: &std::path::Path) -> PathBuf {
101 let mut result = PathBuf::new();
102 for component in path.components() {
103 match component {
104 Component::ParentDir => {
105 result.pop();
106 }
107 Component::CurDir => {}
108 other => result.push(other),
109 }
110 }
111 result
112}
113
114impl Sandbox {
115 pub fn allow_all() -> Self {
116 Sandbox {
117 denied: Caps::NONE,
118 allowed_paths: None,
119 }
120 }
121
122 pub fn deny(caps: Caps) -> Self {
123 Sandbox {
124 denied: caps,
125 allowed_paths: None,
126 }
127 }
128
129 pub fn with_more_denied(mut self, caps: Caps) -> Self {
130 self.denied = self.denied.union(caps);
131 self
132 }
133
134 pub fn with_allowed_paths(mut self, paths: Vec<PathBuf>) -> Self {
135 self.allowed_paths = Some(
136 paths
137 .into_iter()
138 .map(|p| std::fs::canonicalize(&p).unwrap_or(p))
139 .collect(),
140 );
141 self
142 }
143
144 pub fn is_unrestricted(&self) -> bool {
145 self.denied == Caps::NONE && self.allowed_paths.is_none()
146 }
147
148 pub fn check(&self, required: Caps, fn_name: &str) -> Result<(), SemaError> {
149 if required == Caps::NONE {
153 return Ok(());
154 }
155 if self.denied.contains(required) {
156 Err(SemaError::PermissionDenied {
157 function: fn_name.to_string(),
158 capability: required.name().to_string(),
159 })
160 } else {
161 Ok(())
162 }
163 }
164
165 pub fn check_path(&self, path: &str, fn_name: &str) -> Result<(), SemaError> {
172 let allowed = match &self.allowed_paths {
173 Some(paths) => paths,
174 None => return Ok(()),
175 };
176 let p = std::path::Path::new(path);
177 let canonical = std::fs::canonicalize(p).unwrap_or_else(|_| {
178 if let Some(parent) = p.parent() {
179 if let Ok(canon_parent) = std::fs::canonicalize(parent) {
180 return canon_parent.join(p.file_name().unwrap_or_default());
181 }
182 }
183 let abs = if p.is_absolute() {
184 p.to_path_buf()
185 } else {
186 std::env::current_dir()
187 .unwrap_or_else(|_| PathBuf::from("."))
188 .join(p)
189 };
190 normalize_lexical(&abs)
191 });
192 for allowed_path in allowed {
193 if canonical.starts_with(allowed_path) {
194 return Ok(());
195 }
196 }
197 Err(SemaError::PathDenied {
198 function: fn_name.to_string(),
199 path: canonical.display().to_string(),
200 })
201 }
202
203 pub fn parse_allowed_paths(value: &str) -> Vec<PathBuf> {
204 value
205 .split(',')
206 .map(|s| s.trim())
207 .filter(|s| !s.is_empty())
208 .map(|s| {
209 let p = PathBuf::from(s);
210 std::fs::canonicalize(&p).unwrap_or(p)
211 })
212 .collect()
213 }
214
215 pub fn parse_cli(value: &str) -> Result<Self, String> {
216 match value {
217 "strict" => Ok(Sandbox::deny(Caps::STRICT)),
218 "all" => Ok(Sandbox::deny(Caps::ALL)),
219 other => {
220 let mut denied = Caps::NONE;
221 for part in other.split(',') {
222 let part = part.trim();
223 if part.is_empty() {
224 continue;
225 }
226 let name = part.strip_prefix("no-").unwrap_or(part);
227 match Caps::from_name(name) {
228 Some(cap) => denied = denied.union(cap),
229 None => return Err(format!("unknown capability: {name}")),
230 }
231 }
232 Ok(Sandbox::deny(denied))
233 }
234 }
235 }
236}
237
238impl Default for Sandbox {
239 fn default() -> Self {
240 Sandbox::allow_all()
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_caps_contains() {
250 let all = Caps::FS_READ.union(Caps::FS_WRITE).union(Caps::SHELL);
251 assert!(all.contains(Caps::FS_READ));
252 assert!(all.contains(Caps::FS_WRITE));
253 assert!(all.contains(Caps::SHELL));
254 assert!(!all.contains(Caps::NETWORK));
255 assert!(!all.contains(Caps::LLM));
256 }
257
258 #[test]
259 fn test_caps_contains_none_is_always_true() {
260 assert!(Caps::NONE.contains(Caps::NONE));
263 assert!(Caps::ALL.contains(Caps::NONE));
264 }
265
266 #[test]
267 fn test_caps_union() {
268 let combined = Caps::SHELL.union(Caps::NETWORK);
269 assert!(combined.contains(Caps::SHELL));
270 assert!(combined.contains(Caps::NETWORK));
271 assert!(!combined.contains(Caps::FS_READ));
272 }
273
274 #[test]
275 fn test_caps_all_contains_every_cap() {
276 assert!(Caps::ALL.contains(Caps::FS_READ));
277 assert!(Caps::ALL.contains(Caps::FS_WRITE));
278 assert!(Caps::ALL.contains(Caps::SHELL));
279 assert!(Caps::ALL.contains(Caps::NETWORK));
280 assert!(Caps::ALL.contains(Caps::ENV_READ));
281 assert!(Caps::ALL.contains(Caps::ENV_WRITE));
282 assert!(Caps::ALL.contains(Caps::PROCESS));
283 assert!(Caps::ALL.contains(Caps::LLM));
284 assert!(Caps::ALL.contains(Caps::SERIAL));
285 }
286
287 #[test]
288 fn test_caps_strict_preset() {
289 assert!(Caps::STRICT.contains(Caps::SHELL));
290 assert!(Caps::STRICT.contains(Caps::FS_WRITE));
291 assert!(Caps::STRICT.contains(Caps::NETWORK));
292 assert!(Caps::STRICT.contains(Caps::ENV_WRITE));
293 assert!(Caps::STRICT.contains(Caps::PROCESS));
294 assert!(Caps::STRICT.contains(Caps::LLM));
295 assert!(Caps::STRICT.contains(Caps::SERIAL));
296 assert!(!Caps::STRICT.contains(Caps::FS_READ));
298 assert!(!Caps::STRICT.contains(Caps::ENV_READ));
299 }
300
301 #[test]
302 fn test_caps_name_roundtrip() {
303 let caps = [
304 Caps::FS_READ,
305 Caps::FS_WRITE,
306 Caps::SHELL,
307 Caps::NETWORK,
308 Caps::ENV_READ,
309 Caps::ENV_WRITE,
310 Caps::PROCESS,
311 Caps::LLM,
312 Caps::SERIAL,
313 ];
314 for cap in caps {
315 let name = cap.name();
316 assert_eq!(
317 Caps::from_name(name),
318 Some(cap),
319 "roundtrip failed for {name}"
320 );
321 }
322 }
323
324 #[test]
325 fn test_caps_from_name_unknown() {
326 assert_eq!(Caps::from_name("garbage"), None);
327 assert_eq!(Caps::from_name(""), None);
328 }
329
330 #[test]
331 fn test_caps_display() {
332 assert_eq!(format!("{}", Caps::SHELL), "shell");
333 assert_eq!(format!("{}", Caps::NETWORK), "network");
334 assert_eq!(format!("{}", Caps::FS_READ), "fs-read");
335 assert_eq!(format!("{}", Caps::SERIAL), "serial");
336 }
337
338 #[test]
339 fn test_sandbox_allow_all_is_unrestricted() {
340 let sb = Sandbox::allow_all();
341 assert!(sb.is_unrestricted());
342 }
343
344 #[test]
345 fn test_sandbox_deny_is_restricted() {
346 let sb = Sandbox::deny(Caps::SHELL);
347 assert!(!sb.is_unrestricted());
348 }
349
350 #[test]
351 fn test_sandbox_default_is_unrestricted() {
352 let sb = Sandbox::default();
353 assert!(sb.is_unrestricted());
354 }
355
356 #[test]
357 fn test_sandbox_check_allowed() {
358 let sb = Sandbox::deny(Caps::SHELL);
359 assert!(sb.check(Caps::NETWORK, "http/get").is_ok());
360 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
361 }
362
363 #[test]
364 fn test_sandbox_check_denied() {
365 let sb = Sandbox::deny(Caps::SHELL);
366 let err = sb.check(Caps::SHELL, "shell").unwrap_err();
367 assert!(err.to_string().contains("Permission denied"));
368 assert!(err.to_string().contains("shell"));
369 }
370
371 #[test]
372 fn test_sandbox_check_denied_error_format() {
373 let sb = Sandbox::deny(Caps::NETWORK);
374 let err = sb.check(Caps::NETWORK, "http/get").unwrap_err();
375 let msg = err.to_string();
376 assert!(
377 msg.contains("http/get"),
378 "should contain function name: {msg}"
379 );
380 assert!(
381 msg.contains("network"),
382 "should contain capability name: {msg}"
383 );
384 }
385
386 #[test]
387 fn test_sandbox_check_multiple_denied() {
388 let sb = Sandbox::deny(Caps::SHELL.union(Caps::NETWORK));
389 assert!(sb.check(Caps::SHELL, "shell").is_err());
390 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
391 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
392 }
393
394 #[test]
395 fn test_sandbox_parse_cli_strict() {
396 let sb = Sandbox::parse_cli("strict").unwrap();
397 assert!(sb.check(Caps::SHELL, "shell").is_err());
398 assert!(sb.check(Caps::FS_WRITE, "file/write").is_err());
399 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
400 assert!(sb.check(Caps::SERIAL, "serial/list").is_err());
401 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
403 assert!(sb.check(Caps::ENV_READ, "env").is_ok());
404 }
405
406 #[test]
407 fn test_sandbox_parse_cli_all() {
408 let sb = Sandbox::parse_cli("all").unwrap();
409 assert!(sb.check(Caps::SHELL, "shell").is_err());
410 assert!(sb.check(Caps::FS_READ, "file/read").is_err());
411 assert!(sb.check(Caps::ENV_READ, "env").is_err());
412 assert!(sb.check(Caps::SERIAL, "serial/list").is_err());
413 }
414
415 #[test]
416 fn test_sandbox_parse_cli_no_prefix() {
417 let sb = Sandbox::parse_cli("no-shell,no-network").unwrap();
418 assert!(sb.check(Caps::SHELL, "shell").is_err());
419 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
420 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
421 }
422
423 #[test]
424 fn test_sandbox_parse_cli_without_no_prefix() {
425 let sb = Sandbox::parse_cli("shell,network").unwrap();
426 assert!(sb.check(Caps::SHELL, "shell").is_err());
427 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
428 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
429 }
430
431 #[test]
432 fn test_sandbox_parse_cli_single() {
433 let sb = Sandbox::parse_cli("no-fs-write").unwrap();
434 assert!(sb.check(Caps::FS_WRITE, "file/write").is_err());
435 assert!(sb.check(Caps::FS_READ, "file/read").is_ok());
436 }
437
438 #[test]
439 fn test_sandbox_parse_cli_with_spaces() {
440 let sb = Sandbox::parse_cli("no-shell, no-network").unwrap();
441 assert!(sb.check(Caps::SHELL, "shell").is_err());
442 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
443 }
444
445 #[test]
446 fn test_sandbox_parse_cli_empty_parts() {
447 let sb = Sandbox::parse_cli("no-shell,,no-network").unwrap();
448 assert!(sb.check(Caps::SHELL, "shell").is_err());
449 assert!(sb.check(Caps::NETWORK, "http/get").is_err());
450 }
451
452 #[test]
453 fn test_sandbox_parse_cli_invalid() {
454 assert!(Sandbox::parse_cli("no-bogus").is_err());
455 assert!(Sandbox::parse_cli("no-shell,no-bogus").is_err());
456 }
457
458 #[test]
459 fn test_check_path_none_allows_everything() {
460 let sb = Sandbox::allow_all();
461 assert!(sb.check_path("/etc/passwd", "file/read").is_ok());
462 assert!(sb.check_path("relative.txt", "file/read").is_ok());
463 }
464
465 #[test]
466 fn test_check_path_inside_allowed_dir() {
467 let tmp = std::env::temp_dir();
468 let sb = Sandbox::allow_all().with_allowed_paths(vec![tmp.clone()]);
469 let test_path = tmp.join("sema-test-file.txt");
470 std::fs::write(&test_path, "test").ok();
471 assert!(sb
472 .check_path(test_path.to_str().unwrap(), "file/read")
473 .is_ok());
474 let _ = std::fs::remove_file(&test_path);
475 }
476
477 #[test]
478 fn test_check_path_outside_allowed_dir() {
479 let tmp = std::env::temp_dir().join("sema-sandbox-test-dir");
480 std::fs::create_dir_all(&tmp).ok();
481 let sb = Sandbox::allow_all().with_allowed_paths(vec![tmp.clone()]);
482 let result = sb.check_path("/etc/hosts", "file/read");
483 assert!(result.is_err());
484 let err = result.unwrap_err();
485 assert!(err.to_string().contains("Permission denied"), "{err}");
486 assert!(
487 err.to_string().contains("outside allowed directories"),
488 "{err}"
489 );
490 let _ = std::fs::remove_dir_all(&tmp);
491 }
492
493 #[test]
494 fn test_check_path_traversal_attempt() {
495 let tmp = std::env::temp_dir().join("sema-sandbox-traverse");
496 std::fs::create_dir_all(&tmp).ok();
497 let sb = Sandbox::allow_all().with_allowed_paths(vec![tmp.clone()]);
498 let evil = format!("{}/../../../etc/passwd", tmp.display());
499 let result = sb.check_path(&evil, "file/read");
500 assert!(result.is_err(), "path traversal should be denied");
501 let _ = std::fs::remove_dir_all(&tmp);
502 }
503
504 #[test]
505 fn test_check_path_multiple_allowed() {
506 let dir_a = std::env::temp_dir().join("sema-sandbox-a");
507 let dir_b = std::env::temp_dir().join("sema-sandbox-b");
508 std::fs::create_dir_all(&dir_a).ok();
509 std::fs::create_dir_all(&dir_b).ok();
510 let sb = Sandbox::allow_all().with_allowed_paths(vec![dir_a.clone(), dir_b.clone()]);
511 let file_a = dir_a.join("ok.txt");
512 std::fs::write(&file_a, "a").ok();
513 let file_b = dir_b.join("ok.txt");
514 std::fs::write(&file_b, "b").ok();
515 assert!(sb.check_path(file_a.to_str().unwrap(), "file/read").is_ok());
516 assert!(sb.check_path(file_b.to_str().unwrap(), "file/read").is_ok());
517 assert!(sb.check_path("/etc/hosts", "file/read").is_err());
518 let _ = std::fs::remove_dir_all(&dir_a);
519 let _ = std::fs::remove_dir_all(&dir_b);
520 }
521
522 #[test]
523 fn test_parse_allowed_paths() {
524 let paths = Sandbox::parse_allowed_paths("/tmp, /var");
525 assert_eq!(paths.len(), 2);
526 }
527
528 #[test]
529 fn test_parse_allowed_paths_empty_parts() {
530 let paths = Sandbox::parse_allowed_paths("/tmp,,/var,");
531 assert_eq!(paths.len(), 2);
532 }
533
534 #[test]
535 fn test_with_allowed_paths_makes_restricted() {
536 let sb = Sandbox::allow_all().with_allowed_paths(vec![std::path::PathBuf::from("/tmp")]);
537 assert!(!sb.is_unrestricted());
538 }
539
540 #[test]
541 fn test_check_path_nonexistent_component_escape() {
542 let tmp = std::env::temp_dir().join("sema-sandbox-escape");
543 std::fs::create_dir_all(&tmp).ok();
544 let sb = Sandbox::allow_all().with_allowed_paths(vec![tmp.clone()]);
545 let evil = format!("{}/nonexistent/../../etc/passwd", tmp.display());
546 let result = sb.check_path(&evil, "file/write");
547 assert!(
548 result.is_err(),
549 "nonexistent component escape should be denied"
550 );
551 let _ = std::fs::remove_dir_all(&tmp);
552 }
553
554 #[test]
555 fn test_check_path_relative_nonexistent_escape() {
556 let tmp = std::env::temp_dir().join("sema-sandbox-rel-escape");
557 let allowed = tmp.join("allowed");
558 std::fs::create_dir_all(&allowed).ok();
559 let sb = Sandbox::allow_all().with_allowed_paths(vec![allowed.clone()]);
560 let evil = format!("{}/fake/../../../etc/hosts", allowed.display());
561 let result = sb.check_path(&evil, "file/write");
562 assert!(
563 result.is_err(),
564 "relative nonexistent escape should be denied"
565 );
566 let _ = std::fs::remove_dir_all(&tmp);
567 }
568}