Skip to main content

orcs_runtime/
sandbox.rs

1//! File system sandbox implementation.
2//!
3//! The [`SandboxPolicy`] trait and [`SandboxError`] are defined in `orcs-auth`.
4//! This module provides the concrete filesystem implementation:
5//!
6//! - [`ProjectSandbox`] — default sandbox rooted at the project directory
7//!
8//! # Architecture
9//!
10//! ```text
11//! SandboxPolicy trait (orcs-auth)
12//!          │
13//!          ├── ProjectSandbox (THIS MODULE)
14//!          │     project_root: /home/user/myproject (.git detected)
15//!          │     permissive_root: /home/user/myproject (= project_root)
16//!          │
17//!          └── scoped() → Virtual Sandbox (narrower boundary)
18//!                permissive_root: /home/user/myproject/components/x
19//! ```
20//!
21//! # Security
22//!
23//! All paths are canonicalized to prevent symlink/traversal escapes.
24//! Writes validate the deepest existing ancestor for new-file creation.
25//!
26//! ## Known Limitations
27//!
28//! `validate_write` performs a check-then-use sequence on the filesystem.
29//! Between the boundary check and the actual I/O, an attacker with local
30//! access could swap a directory for a symlink (TOCTOU race). In practice
31//! this is mitigated by:
32//!
33//! 1. Running inside a Docker/OS-level sandbox (primary security boundary)
34//! 2. Returning a canonicalized path so callers never follow un-resolved symlinks
35//!
36//! For environments without OS-level sandboxing, consider `openat(2)`-based
37//! path resolution for stronger guarantees.
38
39// Re-export trait and error from orcs-auth for backward compatibility
40pub use orcs_auth::{SandboxError, SandboxPolicy};
41
42use std::path::{Path, PathBuf};
43
44// ─── Concrete Implementation ────────────────────────────────────────
45
46/// Default sandbox rooted at the project directory.
47///
48/// - `project_root` — where `.git`/`.orcs` was detected (immutable after creation)
49/// - `permissive_root` — effective boundary (defaults to `project_root`, narrowed by `scoped()`)
50///
51/// # Example
52///
53/// ```no_run
54/// use orcs_runtime::sandbox::{ProjectSandbox, SandboxPolicy};
55///
56/// let sandbox = ProjectSandbox::new("/home/user/myproject").expect("sandbox init");
57/// assert!(sandbox.validate_read("src/main.rs").is_ok());
58/// assert!(sandbox.validate_read("/etc/passwd").is_err());
59/// ```
60#[derive(Debug, Clone)]
61pub struct ProjectSandbox {
62    project_root: PathBuf,
63    permissive_root: PathBuf,
64}
65
66impl ProjectSandbox {
67    /// Creates a new sandbox rooted at the given project directory.
68    ///
69    /// The path is canonicalized to resolve symlinks.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`SandboxError::Init`] if the path cannot be canonicalized.
74    pub fn new(project_root: impl AsRef<Path>) -> Result<Self, SandboxError> {
75        let root = project_root.as_ref().canonicalize().map_err(|e| {
76            SandboxError::Init(format!(
77                "cannot canonicalize '{}': {e}",
78                project_root.as_ref().display()
79            ))
80        })?;
81
82        Ok(Self {
83            project_root: root.clone(),
84            permissive_root: root,
85        })
86    }
87
88    /// Creates a scoped (virtual) sandbox within this one.
89    ///
90    /// The effective boundary is narrowed to `sub_path` (relative to
91    /// current `root()`). The `project_root` remains unchanged.
92    ///
93    /// # Errors
94    ///
95    /// Returns error if `sub_path` resolves outside the current boundary.
96    pub fn scoped(&self, sub_path: impl AsRef<Path>) -> Result<Self, SandboxError> {
97        let absolute = if sub_path.as_ref().is_absolute() {
98            sub_path.as_ref().to_path_buf()
99        } else {
100            self.permissive_root.join(sub_path.as_ref())
101        };
102
103        let canonical = absolute.canonicalize().map_err(|e| {
104            SandboxError::Init(format!(
105                "cannot canonicalize scoped path '{}': {e}",
106                absolute.display()
107            ))
108        })?;
109
110        if !canonical.starts_with(&self.permissive_root) {
111            return Err(SandboxError::OutsideBoundary {
112                path: absolute.display().to_string(),
113                root: self.permissive_root.display().to_string(),
114            });
115        }
116
117        Ok(Self {
118            project_root: self.project_root.clone(),
119            permissive_root: canonical,
120        })
121    }
122}
123
124impl SandboxPolicy for ProjectSandbox {
125    fn project_root(&self) -> &Path {
126        &self.project_root
127    }
128
129    fn root(&self) -> &Path {
130        &self.permissive_root
131    }
132
133    fn validate_read(&self, path: &str) -> Result<PathBuf, SandboxError> {
134        let absolute = resolve_absolute(path, &self.permissive_root);
135        let canonical = absolute
136            .canonicalize()
137            .map_err(|e| SandboxError::NotFound {
138                path: path.to_string(),
139                source: e,
140            })?;
141
142        if !canonical.starts_with(&self.permissive_root) {
143            return Err(SandboxError::OutsideBoundary {
144                path: path.to_string(),
145                root: self.permissive_root.display().to_string(),
146            });
147        }
148
149        Ok(canonical)
150    }
151
152    fn validate_write(&self, path: &str) -> Result<PathBuf, SandboxError> {
153        let absolute = resolve_absolute(path, &self.permissive_root);
154
155        let mut ancestor = absolute.as_path();
156        loop {
157            if ancestor.exists() {
158                let canonical_ancestor = ancestor.canonicalize().map_err(|e| {
159                    SandboxError::Init(format!("path resolution failed: {path} ({e})"))
160                })?;
161                if !canonical_ancestor.starts_with(&self.permissive_root) {
162                    return Err(SandboxError::OutsideBoundary {
163                        path: path.to_string(),
164                        root: self.permissive_root.display().to_string(),
165                    });
166                }
167                // Return canonicalized ancestor + remaining non-existent suffix.
168                // This prevents callers from following symlinks in the
169                // un-resolved portion of the path.
170                let suffix = absolute.strip_prefix(ancestor).unwrap_or(Path::new(""));
171                if suffix.as_os_str().is_empty() {
172                    return Ok(canonical_ancestor);
173                }
174                return Ok(canonical_ancestor.join(suffix));
175            }
176            match ancestor.parent() {
177                Some(p) if !p.as_os_str().is_empty() => ancestor = p,
178                _ => {
179                    return Err(SandboxError::OutsideBoundary {
180                        path: path.to_string(),
181                        root: self.permissive_root.display().to_string(),
182                    });
183                }
184            }
185        }
186    }
187}
188
189// ─── Helpers ────────────────────────────────────────────────────────
190
191/// Resolves a path to absolute, relative to the given root.
192fn resolve_absolute(path: &str, root: &Path) -> PathBuf {
193    let requested = Path::new(path);
194    if requested.is_absolute() {
195        requested.to_path_buf()
196    } else {
197        root.join(requested)
198    }
199}
200
201// ─── Tests ──────────────────────────────────────────────────────────
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::WorkDir;
207    use std::fs;
208
209    fn test_sandbox() -> (WorkDir, ProjectSandbox) {
210        let tmp = WorkDir::temporary().expect("should create temp WorkDir for sandbox test");
211        let sandbox = ProjectSandbox::new(tmp.path()).expect("should create sandbox from temp dir");
212        (tmp, sandbox)
213    }
214
215    // ─── ProjectSandbox Construction ────────────────────────────────
216
217    #[test]
218    fn new_sandbox_canonicalizes_root() {
219        let (tmp, sandbox) = test_sandbox();
220        let expected = tmp
221            .path()
222            .canonicalize()
223            .expect("should canonicalize temp dir path");
224        assert_eq!(sandbox.root(), expected);
225        assert_eq!(sandbox.project_root(), expected);
226    }
227
228    #[test]
229    fn new_sandbox_nonexistent_path_fails() {
230        let result = ProjectSandbox::new("/nonexistent/path/xyz");
231        assert!(result.is_err());
232    }
233
234    // ─── validate_read ──────────────────────────────────────────────
235
236    #[test]
237    fn read_accepts_file_under_root() {
238        let (tmp, sandbox) = test_sandbox();
239        fs::write(tmp.path().join("ok.txt"), "data").expect("should write ok.txt to temp dir");
240
241        let result = sandbox.validate_read("ok.txt");
242        assert!(result.is_ok());
243    }
244
245    #[test]
246    fn read_accepts_absolute_under_root() {
247        let (tmp, sandbox) = test_sandbox();
248        let file = tmp.path().join("abs.txt");
249        fs::write(&file, "data").expect("should write abs.txt to temp dir");
250
251        let result =
252            sandbox.validate_read(file.to_str().expect("should convert abs.txt path to str"));
253        assert!(result.is_ok());
254    }
255
256    #[test]
257    fn read_rejects_outside_root() {
258        let (_tmp, sandbox) = test_sandbox();
259        let result = sandbox.validate_read("/etc/hosts");
260        assert!(result.is_err());
261        let err = result
262            .expect_err("read outside root should fail")
263            .to_string();
264        assert!(err.contains("access denied"), "got: {err}");
265    }
266
267    #[test]
268    fn read_rejects_traversal_via_dotdot() {
269        let (tmp, sandbox) = test_sandbox();
270        let sub = tmp.path().join("sub");
271        fs::create_dir_all(&sub).expect("should create sub dir");
272        fs::write(tmp.path().join("secret.txt"), "secret")
273            .expect("should write secret.txt to temp dir");
274
275        let scoped = sandbox
276            .scoped("sub")
277            .expect("should create scoped sandbox for sub");
278        let result = scoped.validate_read("../secret.txt");
279        assert!(result.is_err());
280        let err = result
281            .expect_err("traversal via .. should fail")
282            .to_string();
283        assert!(err.contains("access denied"), "got: {err}");
284    }
285
286    #[test]
287    fn read_rejects_nonexistent() {
288        let (_tmp, sandbox) = test_sandbox();
289        let result = sandbox.validate_read("nonexistent.txt");
290        assert!(result.is_err());
291        let err = result
292            .expect_err("read of nonexistent file should fail")
293            .to_string();
294        assert!(err.contains("path not found"), "got: {err}");
295    }
296
297    // ─── validate_write ─────────────────────────────────────────────
298
299    #[test]
300    fn write_accepts_new_file_under_root() {
301        let (_tmp, sandbox) = test_sandbox();
302        let result = sandbox.validate_write("new_file.txt");
303        assert!(result.is_ok());
304    }
305
306    #[test]
307    fn write_accepts_nested_new_file() {
308        let (_tmp, sandbox) = test_sandbox();
309        let result = sandbox.validate_write("sub/deep/new.txt");
310        assert!(result.is_ok());
311    }
312
313    #[test]
314    fn write_rejects_outside_root() {
315        let (_tmp, sandbox) = test_sandbox();
316        let result = sandbox.validate_write("/etc/evil.txt");
317        assert!(result.is_err());
318        let err = result
319            .expect_err("write outside root should fail")
320            .to_string();
321        assert!(err.contains("access denied"), "got: {err}");
322    }
323
324    #[test]
325    fn write_rejects_traversal_via_dotdot() {
326        let (_tmp, sandbox) = test_sandbox();
327        let result = sandbox.validate_write("../escape.txt");
328        assert!(result.is_err());
329        let err = result
330            .expect_err("write via .. traversal should fail")
331            .to_string();
332        assert!(err.contains("access denied"), "got: {err}");
333    }
334
335    // ─── scoped (Virtual Sandbox) ───────────────────────────────────
336
337    #[test]
338    fn scoped_narrows_boundary() {
339        let (tmp, sandbox) = test_sandbox();
340        let sub = tmp.path().join("components");
341        fs::create_dir_all(&sub).expect("should create components dir");
342        fs::write(sub.join("comp.lua"), "-- lua").expect("should write comp.lua");
343        fs::write(tmp.path().join("top.txt"), "top").expect("should write top.txt");
344
345        let scoped = sandbox
346            .scoped("components")
347            .expect("should create scoped sandbox for components dir");
348
349        // Can read within scoped boundary
350        assert!(scoped.validate_read("comp.lua").is_ok());
351
352        // Cannot read parent's files
353        assert!(scoped.validate_read("../top.txt").is_err());
354    }
355
356    #[test]
357    fn scoped_preserves_project_root() {
358        let (tmp, sandbox) = test_sandbox();
359        let sub = tmp.path().join("sub");
360        fs::create_dir_all(&sub).expect("should create sub dir for scoped test");
361
362        let scoped = sandbox
363            .scoped("sub")
364            .expect("should create scoped sandbox for sub");
365        assert_eq!(scoped.project_root(), sandbox.project_root());
366        assert_ne!(scoped.root(), sandbox.root());
367    }
368
369    #[test]
370    fn scoped_rejects_outside_parent() {
371        let (_tmp, sandbox) = test_sandbox();
372        let result = sandbox.scoped("/etc");
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn scoped_nonexistent_subdir_fails() {
378        let (_tmp, sandbox) = test_sandbox();
379        let result = sandbox.scoped("nonexistent_sub");
380        assert!(result.is_err());
381    }
382
383    // ─── SandboxPolicy trait object ─────────────────────────────────
384
385    #[test]
386    fn trait_object_works() {
387        let (tmp, sandbox) = test_sandbox();
388        fs::write(tmp.path().join("trait_test.txt"), "ok").expect("should write trait_test.txt");
389
390        let policy: Box<dyn SandboxPolicy> = Box::new(sandbox);
391        assert!(policy.validate_read("trait_test.txt").is_ok());
392        assert!(policy.validate_read("/etc/hosts").is_err());
393    }
394
395    #[test]
396    fn arc_trait_object_works() {
397        use std::sync::Arc;
398
399        let (tmp, sandbox) = test_sandbox();
400        fs::write(tmp.path().join("arc_test.txt"), "ok").expect("should write arc_test.txt");
401
402        let policy: Arc<dyn SandboxPolicy> = Arc::new(sandbox);
403        let clone = Arc::clone(&policy);
404
405        assert!(policy.validate_read("arc_test.txt").is_ok());
406        assert!(clone.validate_write("new.txt").is_ok());
407    }
408
409    // ─── Property-Based Tests ─────────────────────────────────────
410
411    mod proptest_sandbox {
412        use super::*;
413        use proptest::prelude::*;
414
415        /// Strategy: arbitrary path-like strings (printable, slashes, dots)
416        fn path_strategy() -> impl Strategy<Value = String> {
417            prop::string::string_regex("[a-zA-Z0-9_./ -]{0,128}")
418                .expect("regex should be valid for path strategy")
419        }
420
421        /// Strategy: paths containing traversal patterns
422        fn traversal_strategy() -> impl Strategy<Value = String> {
423            prop::string::string_regex("([a-z]{0,8}/)*\\.\\./([a-z]{0,8}/)*[a-z]{1,8}\\.txt")
424                .expect("regex should be valid for traversal strategy")
425        }
426
427        proptest! {
428            /// validate_read never panics on arbitrary input
429            #[test]
430            fn read_never_panics(path in path_strategy()) {
431                let (tmp, sandbox) = test_sandbox();
432                let _ = fs::write(tmp.path().join("exists.txt"), "x");
433                let _ = sandbox.validate_read(&path);
434            }
435
436            /// validate_write never panics on arbitrary input
437            #[test]
438            fn write_never_panics(path in path_strategy()) {
439                let (_tmp, sandbox) = test_sandbox();
440                let _ = sandbox.validate_write(&path);
441            }
442
443            /// Any path containing ../ in a scoped sandbox is rejected
444            #[test]
445            fn scoped_rejects_all_traversal(path in traversal_strategy()) {
446                let (tmp, sandbox) = test_sandbox();
447                let sub = tmp.path().join("sub");
448                let _ = fs::create_dir_all(&sub);
449                if let Ok(scoped) = sandbox.scoped("sub") {
450                    let result = scoped.validate_read(&path);
451                    prop_assert!(
452                        result.is_err(),
453                        "traversal path '{}' should be rejected in scoped sandbox",
454                        path
455                    );
456                }
457            }
458
459            /// Files created inside root are always readable
460            #[test]
461            fn internal_files_always_readable(name in "[a-z]{1,16}\\.txt") {
462                let (tmp, sandbox) = test_sandbox();
463                let _ = fs::write(tmp.path().join(&name), "data");
464                let result = sandbox.validate_read(&name);
465                prop_assert!(result.is_ok(), "file '{}' inside sandbox should be readable", name);
466            }
467
468            /// Files created inside root are always writable
469            #[test]
470            fn internal_paths_always_writable(name in "[a-z]{1,16}\\.txt") {
471                let (_tmp, sandbox) = test_sandbox();
472                let result = sandbox.validate_write(&name);
473                prop_assert!(result.is_ok(), "path '{}' inside sandbox should be writable", name);
474            }
475
476            /// Absolute paths outside root are always rejected for write
477            #[test]
478            fn absolute_outside_rejected(name in "[a-z]{1,16}\\.txt") {
479                let (_tmp, sandbox) = test_sandbox();
480                let path = format!("/tmp/orcs-proptest-outside/{}", name);
481                let result = sandbox.validate_write(&path);
482                prop_assert!(result.is_err(), "absolute path '{}' outside root should be rejected", path);
483            }
484        }
485    }
486
487    // ─── Symlink Attack Tests ───────────────────────────────────────
488
489    #[cfg(unix)]
490    mod symlink_tests {
491        use super::*;
492        use std::os::unix::fs::symlink;
493
494        #[test]
495        fn read_rejects_symlink_escape() {
496            let (tmp, sandbox) = test_sandbox();
497            symlink("/etc/hosts", tmp.path().join("evil_link"))
498                .expect("should create symlink to /etc/hosts");
499
500            let result = sandbox.validate_read("evil_link");
501            assert!(result.is_err());
502            assert!(
503                result
504                    .expect_err("symlink escape read should fail")
505                    .to_string()
506                    .contains("access denied"),
507                "symlink to /etc/hosts should be rejected"
508            );
509        }
510
511        #[test]
512        fn write_rejects_symlink_parent_escape() {
513            let (tmp, sandbox) = test_sandbox();
514            let outside =
515                WorkDir::temporary().expect("should create outside temp WorkDir for symlink test");
516            symlink(outside.path(), tmp.path().join("escape_dir"))
517                .expect("should create symlink to outside dir");
518
519            let result = sandbox.validate_write("escape_dir/evil.txt");
520            assert!(result.is_err());
521            assert!(
522                result
523                    .expect_err("symlink parent escape write should fail")
524                    .to_string()
525                    .contains("access denied"),
526                "symlink directory escape should be rejected"
527            );
528        }
529
530        #[test]
531        fn read_allows_symlink_within_sandbox() {
532            let (tmp, sandbox) = test_sandbox();
533            let real = tmp.path().join("real.txt");
534            fs::write(&real, "ok").expect("should write real.txt for internal symlink test");
535            symlink(&real, tmp.path().join("good_link"))
536                .expect("should create symlink within sandbox");
537
538            let result = sandbox.validate_read("good_link");
539            assert!(result.is_ok(), "symlink within sandbox should be allowed");
540        }
541
542        #[test]
543        fn scoped_read_rejects_symlink_to_parent() {
544            let (tmp, sandbox) = test_sandbox();
545            let sub = tmp.path().join("sub");
546            fs::create_dir_all(&sub).expect("should create sub dir for scoped symlink test");
547            fs::write(tmp.path().join("secret.txt"), "secret")
548                .expect("should write secret.txt for scoped symlink test");
549            symlink(tmp.path().join("secret.txt"), sub.join("link_to_parent"))
550                .expect("should create symlink to parent file");
551
552            let scoped = sandbox
553                .scoped("sub")
554                .expect("should create scoped sandbox for sub dir");
555            let result = scoped.validate_read("link_to_parent");
556            assert!(
557                result.is_err(),
558                "symlink escaping scoped sandbox should be rejected"
559            );
560        }
561    }
562}