Skip to main content

heartbit_core/
sandbox.rs

1//! Path-level sandbox policy shared across filesystem-touching builtins.
2
3#![allow(missing_docs)]
4use std::path::{Path, PathBuf};
5
6use crate::error::Error;
7
8/// Path-level policy for filesystem-touching tools.
9///
10/// Lives in heartbit-core (not in the umbrella) so all filesystem
11/// builtins (bash, patch, edit, write, read) can share enforcement.
12/// The umbrella's `SandboxPolicy` (landlock-backed on Linux) will
13/// compose a `CorePathPolicy` for the path-allowlist piece. Until
14/// Task 5 lands, the two are independent.
15#[derive(Debug, Clone)]
16pub struct CorePathPolicy {
17    allowed_dirs: Vec<PathBuf>,
18    deny_globs: Vec<glob::Pattern>,
19}
20
21impl CorePathPolicy {
22    pub fn builder() -> CorePathPolicyBuilder {
23        CorePathPolicyBuilder::default()
24    }
25
26    /// Returns the canonicalized allowed directories. Used by `SandboxPolicy::from_path_policy`
27    /// to derive Landlock read/write path lists.
28    pub fn allowed_dirs(&self) -> &[PathBuf] {
29        &self.allowed_dirs
30    }
31
32    /// Returns `Ok(())` if `path` is allowed, `Err(Error::Sandbox(...))` otherwise.
33    /// Canonicalizes the input so symlinks pointing outside `allowed_dirs`
34    /// are rejected.
35    pub fn check_path(&self, path: &Path) -> Result<(), Error> {
36        let canonical = path
37            .canonicalize()
38            .map_err(|e| Error::Sandbox(format!("canonicalize {}: {e}", path.display())))?;
39
40        self.check_canonical(&canonical)
41    }
42
43    /// Like [`check_path`] but for files that don't exist yet (about to be
44    /// created or overwritten). Canonicalizes the parent directory then
45    /// recomposes `parent.canonicalize() + file_name` to produce a path that
46    /// is bound to the *real* parent (not a symlink to elsewhere). The
47    /// returned `PathBuf` is the canonical target the caller should write to.
48    ///
49    /// SECURITY (F-FS-1): the previous pattern of "walk up to first existing
50    /// ancestor, then `check_path` on it" left a TOCTOU window: between the
51    /// check and the write, an attacker (or another tool call dispatched in
52    /// parallel via `tokio::JoinSet`) could replace an intermediate component
53    /// with a symlink pointing outside the workspace, and the write would
54    /// follow the symlink. Combine this method with `O_NOFOLLOW` on the open
55    /// syscall to close the race window entirely.
56    pub fn check_path_for_create(&self, path: &Path) -> Result<PathBuf, Error> {
57        let parent = path
58            .parent()
59            .ok_or_else(|| Error::Sandbox(format!("path has no parent: {}", path.display())))?;
60        let canonical_parent = parent.canonicalize().map_err(|e| {
61            Error::Sandbox(format!("canonicalize parent {}: {e}", parent.display()))
62        })?;
63        let file_name = path.file_name().ok_or_else(|| {
64            Error::Sandbox(format!(
65                "path has no file name component: {}",
66                path.display()
67            ))
68        })?;
69        let composed = canonical_parent.join(file_name);
70        self.check_canonical(&composed)?;
71        Ok(composed)
72    }
73
74    fn check_canonical(&self, canonical: &Path) -> Result<(), Error> {
75        let allowed = self
76            .allowed_dirs
77            .iter()
78            .any(|root| canonical.starts_with(root));
79        if !allowed {
80            return Err(Error::Sandbox(format!(
81                "path {} not under any allowed directory",
82                canonical.display()
83            )));
84        }
85
86        for pat in &self.deny_globs {
87            if pat.matches_path(canonical) {
88                return Err(Error::Sandbox(format!(
89                    "path {} matches deny pattern {}",
90                    canonical.display(),
91                    pat.as_str()
92                )));
93            }
94        }
95
96        Ok(())
97    }
98}
99
100#[derive(Default, Debug)]
101pub struct CorePathPolicyBuilder {
102    allowed_dirs: Vec<PathBuf>,
103    deny_globs: Vec<String>,
104}
105
106impl CorePathPolicyBuilder {
107    /// Allow filesystem operations under `dir`. The directory is
108    /// canonicalized at `build()` time; passing a path that doesn't
109    /// exist or that the process can't resolve causes `build()` to
110    /// return `Err(Error::Sandbox(...))`.
111    pub fn allow_dir(mut self, dir: impl AsRef<Path>) -> Self {
112        self.allowed_dirs.push(dir.as_ref().to_path_buf());
113        self
114    }
115
116    /// Deny any path matching this glob even if it falls under an allowed dir.
117    pub fn deny_glob(mut self, pat: impl Into<String>) -> Self {
118        self.deny_globs.push(pat.into());
119        self
120    }
121
122    pub fn build(self) -> Result<CorePathPolicy, Error> {
123        let allowed_dirs = self
124            .allowed_dirs
125            .into_iter()
126            .map(|p| {
127                p.canonicalize()
128                    .map_err(|e| Error::Sandbox(format!("allow_dir {}: {e}", p.display())))
129            })
130            .collect::<Result<Vec<_>, _>>()?;
131
132        let deny_globs = self
133            .deny_globs
134            .into_iter()
135            .map(|p| {
136                glob::Pattern::new(&p)
137                    .map_err(|e| Error::Sandbox(format!("invalid deny glob {p}: {e}")))
138            })
139            .collect::<Result<Vec<_>, _>>()?;
140
141        Ok(CorePathPolicy {
142            allowed_dirs,
143            deny_globs,
144        })
145    }
146}
147
148#[cfg(all(target_os = "linux", feature = "sandbox"))]
149pub use landlock_sandbox::SandboxPolicy;
150
151#[cfg(all(target_os = "linux", feature = "sandbox"))]
152mod landlock_sandbox {
153    use std::io;
154    use std::path::PathBuf;
155    use std::sync::Arc;
156
157    use landlock::{
158        ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
159    };
160
161    use super::CorePathPolicy;
162    use crate::error::Error;
163
164    /// Filesystem sandbox policy applied to bash subprocess via `pre_exec`.
165    ///
166    /// Composes a `CorePathPolicy` (path allowlist + glob denylist, shared with
167    /// non-bash filesystem tools) plus the kernel-level Landlock enforcement.
168    #[derive(Debug, Clone)]
169    pub struct SandboxPolicy {
170        /// Application-level path policy. Shared with read/write/edit/patch tools.
171        path_policy: Arc<CorePathPolicy>,
172        /// Paths with read-only access (Landlock layer).
173        pub read_paths: Vec<PathBuf>,
174        /// Paths with read-write access (Landlock layer).
175        pub write_paths: Vec<PathBuf>,
176    }
177
178    impl SandboxPolicy {
179        /// Default policy: R/W on workspace, read-only on system dirs.
180        ///
181        /// **BREAKING CHANGE (F-FS-3)**: `/tmp` is no longer included in
182        /// `read_paths` or `write_paths`. Sticky-writable shared `/tmp` is a
183        /// vector for cross-tenant TOCTOU and information disclosure on
184        /// shared hosts. Callers that need scratch space should pass a
185        /// per-session subdir under `std::env::temp_dir()` (created `0o700`)
186        /// to a custom `CorePathPolicy::builder().allow_dir(...)` and use
187        /// [`SandboxPolicy::from_path_policy`] instead.
188        pub fn workspace_only(workspace: &std::path::Path) -> Self {
189            let read_paths = vec![
190                PathBuf::from("/usr"),
191                PathBuf::from("/lib"),
192                PathBuf::from("/lib64"),
193                PathBuf::from("/bin"),
194                PathBuf::from("/etc"),
195                workspace.to_path_buf(),
196            ];
197            let write_paths = vec![workspace.to_path_buf()];
198            // Build a corresponding CorePathPolicy (best-effort: skip missing dirs).
199            let mut builder = CorePathPolicy::builder();
200            for p in read_paths.iter().chain(write_paths.iter()) {
201                if p.exists() {
202                    builder = builder.allow_dir(p);
203                }
204            }
205            let path_policy = Arc::new(builder.build().unwrap_or_else(|e| {
206                // workspace_only registers only existing directories and zero deny globs,
207                // so CorePathPolicyBuilder::build() cannot fail here in practice. If it
208                // does, panic loudly rather than silently substituting an empty policy
209                // (which would leave the Landlock layer permissive but the path-policy
210                // layer denying everything — a split-brain).
211                unreachable!(
212                    "CorePathPolicy build failed in workspace_only despite filtered inputs: {e}"
213                )
214            }));
215            Self {
216                path_policy,
217                read_paths,
218                write_paths,
219            }
220        }
221
222        /// Build a `SandboxPolicy` from an externally-constructed `CorePathPolicy`.
223        ///
224        /// Used when one path policy is shared across multiple tools (bash + write +
225        /// edit + ...). The Landlock layer (`read_paths` / `write_paths`) is
226        /// **derived** from the path policy's `allowed_dirs`: all allowed dirs are
227        /// treated as both readable and writable at the kernel level. The path
228        /// policy's deny-globs still gate specific paths inside (application layer).
229        ///
230        /// Without this derivation, `into_pre_exec()` would build a Landlock ruleset
231        /// that handles write-access but adds no `PathBeneath` rules — locking the
232        /// subprocess out of all filesystem access in release builds (Landlock denies
233        /// all handled-but-unruled accesses).
234        ///
235        /// If `allowed_dirs` is empty the Landlock layer will be empty too; the
236        /// `debug_assert!` in `into_pre_exec` will catch this in debug/test builds.
237        pub fn from_path_policy(path_policy: Arc<CorePathPolicy>) -> Self {
238            // Derive Landlock read/write paths from the path policy's allowed_dirs.
239            // Treat all allowed dirs as readable and writable; the path policy's
240            // deny_globs still gate specific paths inside (app-layer enforcement).
241            let dirs: Vec<PathBuf> = path_policy.allowed_dirs().to_vec();
242            Self {
243                path_policy,
244                read_paths: dirs.clone(),
245                write_paths: dirs,
246            }
247        }
248
249        /// Expose the inner `CorePathPolicy` so callers can pass it to non-bash
250        /// filesystem tools that take `Arc<CorePathPolicy>`.
251        pub fn path_policy(&self) -> Arc<CorePathPolicy> {
252            self.path_policy.clone()
253        }
254
255        /// Create a `pre_exec` closure that applies Landlock rules.
256        pub fn into_pre_exec(self) -> Result<impl FnMut() -> io::Result<()>, Error> {
257            debug_assert!(
258                !self.read_paths.is_empty() || !self.write_paths.is_empty(),
259                "SandboxPolicy::into_pre_exec called with empty read_paths AND write_paths; \
260                 the resulting Landlock ruleset would lock the subprocess out of all \
261                 filesystem access. Check that [sandbox].allowed_dirs is non-empty in \
262                 your TOML config, or use workspace_only() to derive paths from a directory."
263            );
264
265            let abi = ABI::V5;
266
267            let read_access = AccessFs::from_read(abi);
268            let write_access = AccessFs::from_all(abi);
269
270            let read_fds: Vec<_> = self
271                .read_paths
272                .iter()
273                .filter_map(|p| PathFd::new(p).ok())
274                .collect();
275
276            let write_fds: Vec<_> = self
277                .write_paths
278                .iter()
279                .filter_map(|p| PathFd::new(p).ok())
280                .collect();
281
282            Ok(move || {
283                let mut ruleset = Ruleset::default()
284                    .handle_access(write_access)
285                    .map_err(|e| io::Error::other(e.to_string()))?
286                    .create()
287                    .map_err(|e| io::Error::other(e.to_string()))?;
288
289                for fd in &read_fds {
290                    ruleset = ruleset
291                        .add_rule(PathBeneath::new(fd, read_access))
292                        .map_err(|e| io::Error::other(e.to_string()))?;
293                }
294
295                for fd in &write_fds {
296                    ruleset = ruleset
297                        .add_rule(PathBeneath::new(fd, write_access))
298                        .map_err(|e| io::Error::other(e.to_string()))?;
299                }
300
301                ruleset
302                    .restrict_self()
303                    .map_err(|e| io::Error::other(e.to_string()))?;
304
305                Ok(())
306            })
307        }
308    }
309
310    #[cfg(test)]
311    mod tests {
312        use super::*;
313
314        #[test]
315        fn workspace_only_includes_system_dirs() {
316            let dir = tempfile::tempdir().unwrap();
317            let policy = SandboxPolicy::workspace_only(dir.path());
318            assert!(policy.read_paths.contains(&PathBuf::from("/usr")));
319            assert!(policy.read_paths.contains(&PathBuf::from("/bin")));
320            assert!(policy.read_paths.contains(&PathBuf::from("/etc")));
321        }
322
323        /// SECURITY (F-FS-3): `/tmp` MUST NOT be in either read or write paths.
324        /// World-writable shared `/tmp` is a vector for cross-tenant TOCTOU
325        /// and information disclosure on shared hosts.
326        #[test]
327        fn workspace_only_excludes_tmp() {
328            let dir = tempfile::tempdir().unwrap();
329            let policy = SandboxPolicy::workspace_only(dir.path());
330            assert!(
331                !policy.read_paths.contains(&PathBuf::from("/tmp")),
332                "/tmp must NOT be readable by default (F-FS-3)"
333            );
334            assert!(
335                !policy.write_paths.contains(&PathBuf::from("/tmp")),
336                "/tmp must NOT be writable by default (F-FS-3)"
337            );
338        }
339
340        #[test]
341        fn into_pre_exec_succeeds_on_workspace() {
342            let dir = tempfile::tempdir().unwrap();
343            let policy = SandboxPolicy::workspace_only(dir.path());
344            let result = policy.into_pre_exec();
345            assert!(result.is_ok());
346        }
347
348        #[test]
349        fn workspace_only_includes_workspace_in_read_and_write() {
350            let dir = tempfile::tempdir().unwrap();
351            let policy = SandboxPolicy::workspace_only(dir.path());
352            assert!(policy.read_paths.contains(&dir.path().to_path_buf()));
353            assert!(policy.write_paths.contains(&dir.path().to_path_buf()));
354        }
355
356        #[test]
357        fn from_path_policy_exposes_inner_policy() {
358            let path_policy = Arc::new(
359                CorePathPolicy::builder()
360                    .allow_dir(std::env::temp_dir())
361                    .build()
362                    .unwrap(),
363            );
364            let sandbox = SandboxPolicy::from_path_policy(path_policy.clone());
365            assert!(Arc::ptr_eq(&path_policy, &sandbox.path_policy()));
366            // from_path_policy now derives Landlock paths from allowed_dirs.
367            assert!(!sandbox.read_paths.is_empty());
368            assert!(!sandbox.write_paths.is_empty());
369        }
370
371        #[test]
372        fn from_path_policy_derives_read_write_paths_from_allowed_dirs() {
373            let dir = tempfile::tempdir().unwrap();
374            let policy = Arc::new(
375                CorePathPolicy::builder()
376                    .allow_dir(dir.path())
377                    .build()
378                    .unwrap(),
379            );
380            let sandbox = SandboxPolicy::from_path_policy(policy);
381            assert_eq!(sandbox.read_paths.len(), 1);
382            assert_eq!(sandbox.write_paths.len(), 1);
383            let canonical = dir.path().canonicalize().unwrap();
384            assert!(sandbox.read_paths.contains(&canonical));
385            assert!(sandbox.write_paths.contains(&canonical));
386        }
387
388        #[test]
389        fn workspace_only_populates_path_policy() {
390            let dir = tempfile::tempdir().unwrap();
391            let policy = SandboxPolicy::workspace_only(dir.path());
392            // The inner CorePathPolicy should accept paths under dir
393            let inner = policy.path_policy();
394            let file = dir.path().join("ok.txt");
395            std::fs::write(&file, b"x").unwrap();
396            assert!(inner.check_path(&file).is_ok());
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use std::fs;
405
406    fn tmp() -> tempfile::TempDir {
407        tempfile::tempdir().unwrap()
408    }
409
410    #[test]
411    fn allows_path_under_allowed_dir() {
412        let root = tmp();
413        let file = root.path().join("ok.txt");
414        fs::write(&file, b"x").unwrap();
415        let policy = CorePathPolicy::builder()
416            .allow_dir(root.path())
417            .build()
418            .unwrap();
419        assert!(policy.check_path(&file).is_ok());
420    }
421
422    #[test]
423    fn denies_path_outside_allowed_dirs() {
424        let root = tmp();
425        let policy = CorePathPolicy::builder()
426            .allow_dir(root.path())
427            .build()
428            .unwrap();
429        let bad_dir = tmp();
430        let bad = bad_dir.path().join("x.txt");
431        fs::write(&bad, b"x").unwrap();
432        let err = policy.check_path(&bad).unwrap_err();
433        assert!(matches!(err, Error::Sandbox(_)));
434    }
435
436    #[test]
437    fn denies_glob_match_inside_allowed_dir() {
438        let root = tmp();
439        let dotenv = root.path().join(".env");
440        fs::write(&dotenv, b"x").unwrap();
441        let policy = CorePathPolicy::builder()
442            .allow_dir(root.path())
443            .deny_glob("**/.env")
444            .build()
445            .unwrap();
446        let err = policy.check_path(&dotenv).unwrap_err();
447        assert!(matches!(err, Error::Sandbox(_)));
448    }
449
450    #[test]
451    fn empty_allowlist_denies_everything() {
452        let policy = CorePathPolicy::builder().build().unwrap();
453        let some_path = std::env::temp_dir();
454        let err = policy.check_path(&some_path).unwrap_err();
455        assert!(matches!(err, Error::Sandbox(_)));
456    }
457
458    #[test]
459    fn invalid_glob_pattern_returns_error() {
460        let result = CorePathPolicy::builder().deny_glob("[unclosed").build();
461        assert!(result.is_err());
462    }
463
464    #[test]
465    fn allow_dir_with_nonexistent_path_fails_at_build() {
466        let bogus = std::env::temp_dir().join(format!("does-not-exist-{}", uuid::Uuid::new_v4()));
467        let result = CorePathPolicy::builder().allow_dir(&bogus).build();
468        assert!(result.is_err());
469    }
470
471    #[cfg(unix)]
472    #[test]
473    fn denies_symlink_pointing_outside_allowed_dir() {
474        use std::os::unix::fs::symlink;
475        let allowed = tmp();
476        let outside = tmp();
477        let outside_file = outside.path().join("secret.txt");
478        fs::write(&outside_file, b"secret").unwrap();
479
480        // Create a symlink inside the allowed dir that points OUTSIDE.
481        let link = allowed.path().join("link.txt");
482        symlink(&outside_file, &link).unwrap();
483
484        let policy = CorePathPolicy::builder()
485            .allow_dir(allowed.path())
486            .build()
487            .unwrap();
488        let err = policy.check_path(&link).unwrap_err();
489        assert!(matches!(err, Error::Sandbox(_)));
490    }
491}