Skip to main content

sema_core/
sandbox.rs

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        // Requesting zero capabilities always succeeds. Without this guard,
150        // `denied.contains(Caps::NONE)` is vacuously true, so a restricted
151        // sandbox would wrongly deny a `Caps::NONE` request (CORE-3).
152        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    // NOTE: check_path validates the path before the file operation, not during it.
166    // This means a TOCTOU (time-of-check-to-time-of-use) window exists where an external
167    // process could swap a symlink between the check and the actual fs operation. Mitigating
168    // this properly requires OS-specific secure open patterns (openat with O_NOFOLLOW, dirfds)
169    // which is a significantly larger change. The current approach is on par with most
170    // scripting language sandboxes.
171    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        // NONE.contains(NONE) = true since (0 & 0 == 0), but this should not
261        // cause false positives in Sandbox::check because check guards against NONE.
262        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        // strict does NOT deny read-only operations
297        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        // strict allows reads
402        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}