Skip to main content

flow_security/
sandbox.rs

1//! Sandboxing primitives for shell commands run by `flow-adapter-shell`.
2//!
3//! The module exposes a capability-declarative API: each shell node carries a
4//! [`Capabilities`] struct (network access, write paths, read paths, env
5//! policy). Given a `Capabilities` plus a `cwd`, [`resolve_layer`] picks the
6//! enforcement strategy the host OS supports, and [`wrap_command`] returns a
7//! `tokio::process::Command` already configured to run under that strategy.
8//!
9//! Layers (in increasing order of enforcement):
10//!
11//! - [`SandboxLayer::Lightweight`] - always applied. Pins cwd, scrubs env per
12//!   [`EnvPolicy`]. Output cap and timeout are enforced by the calling
13//!   adapter (they live closer to the spawn loop).
14//! - [`SandboxLayer::MacosSandboxExec`] - on macOS only, when `Capabilities`
15//!   carries non-empty write/read paths or `net == false`. Wraps the user
16//!   command with `sandbox-exec -p '<generated SBPL>'`. Apple deprecated
17//!   `sandbox-exec` but it still works on macOS 15; the comment block at the
18//!   top of [`build_macos_sbpl`] covers the migration path.
19//! - [`SandboxLayer::LinuxLandlock`] - on Linux when the kernel exposes
20//!   landlock (5.13+). v1 returns the layer marker so the adapter records it
21//!   in the audit log; the actual `prctl`/`landlock_create_ruleset` syscalls
22//!   are wired in a follow-up so we don't gate the whole build on a Linux-only
23//!   crate.
24//! - [`SandboxLayer::None`] - Windows, or when capability declarations are
25//!   absent. The lightweight rails still apply; nothing OS-level enforces
26//!   the declared capabilities.
27//!
28//! See the [project docs](../../../../docs/architecture/adapters.md) for the
29//! end-to-end story and the sandbox matrix.
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35use tokio::process::Command;
36
37/// Per-node capability declaration. Defaults match the most permissive
38/// "trust + log" baseline (network on, write to cwd, env scrubbed) so a node
39/// without an explicit `capabilities` field still works the same as a manual
40/// shell invocation.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Capabilities {
43    #[serde(default = "default_net")]
44    pub net: bool,
45    /// Paths the command may write to. The literal token `"cwd"` is
46    /// substituted with the actual `cwd` at wrap time.
47    #[serde(default)]
48    pub write_paths: Vec<String>,
49    /// Paths the command may read from. Empty means read-anywhere (the
50    /// command inherits the OS's default read access). Same `"cwd"` token
51    /// substitution as `write_paths`.
52    #[serde(default)]
53    pub read_paths: Vec<String>,
54    /// Environment forwarding policy.
55    #[serde(default)]
56    pub env: EnvPolicy,
57}
58
59fn default_net() -> bool {
60    true
61}
62
63impl Default for Capabilities {
64    /// Permissive default: no OS sandbox layer engaged. The lightweight
65    /// rails (cwd pin, env scrub, output cap, timeout) still apply.
66    /// Nodes that want stricter enforcement declare their own
67    /// `capabilities` in `node.data` and the OS layer kicks in.
68    fn default() -> Self {
69        Self {
70            net: true,
71            write_paths: Vec::new(),
72            read_paths: Vec::new(),
73            env: EnvPolicy::default(),
74        }
75    }
76}
77
78/// Environment forwarding policy. `Scrubbed` is the default and matches the
79/// allow-list used by major sandboxing tools.
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum EnvPolicy {
83    /// Forward only `HOME`, `USER`, `PATH`, `LANG`, `LC_*`, `TERM`, `SHELL`.
84    #[default]
85    Scrubbed,
86    /// Forward the full inherited environment. Use sparingly; this is how
87    /// `AWS_*` or `GITHUB_TOKEN` would leak into a curated tool node.
88    Inherit,
89    /// Explicit allow-list of variable names.
90    Vars(Vec<String>),
91}
92
93/// Strategy used to enforce a [`Capabilities`] declaration on the host.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum SandboxLayer {
97    None,
98    Lightweight,
99    MacosSandboxExec,
100    LinuxLandlock,
101}
102
103/// Pick the strongest enforcement layer the host supports for these
104/// capabilities. Always returns at least [`SandboxLayer::Lightweight`].
105pub fn resolve_layer(caps: &Capabilities) -> SandboxLayer {
106    let needs_os = !caps.net || !caps.write_paths.is_empty() || !caps.read_paths.is_empty();
107    if !needs_os {
108        return SandboxLayer::Lightweight;
109    }
110    #[cfg(target_os = "macos")]
111    {
112        SandboxLayer::MacosSandboxExec
113    }
114    #[cfg(target_os = "linux")]
115    {
116        if landlock_available() {
117            return SandboxLayer::LinuxLandlock;
118        }
119        return SandboxLayer::Lightweight;
120    }
121    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
122    {
123        SandboxLayer::Lightweight
124    }
125}
126
127#[cfg(target_os = "linux")]
128fn landlock_available() -> bool {
129    // Minimum-viable probe: read /proc/self/status for "Landlock" disabled
130    // line, or use prctl. v1 returns false to keep the layer marker honest;
131    // a follow-up wires the real ruleset application and flips this to a
132    // genuine availability check.
133    false
134}
135
136/// Forward-only set used by [`EnvPolicy::Scrubbed`].
137const SCRUB_ALLOW: &[&str] = &[
138    "HOME", "USER", "PATH", "LANG", "TERM", "SHELL",
139    // LC_* handled by prefix below.
140];
141
142fn lc_prefix(name: &str) -> bool {
143    name.starts_with("LC_")
144}
145
146/// Build the env map a child should inherit per the declared policy.
147pub fn scrub_env(policy: &EnvPolicy) -> HashMap<String, String> {
148    match policy {
149        EnvPolicy::Inherit => std::env::vars().collect(),
150        EnvPolicy::Scrubbed => std::env::vars()
151            .filter(|(k, _)| SCRUB_ALLOW.contains(&k.as_str()) || lc_prefix(k))
152            .collect(),
153        EnvPolicy::Vars(allow) => {
154            let allow: Vec<&str> = allow.iter().map(String::as_str).collect();
155            std::env::vars()
156                .filter(|(k, _)| allow.contains(&k.as_str()))
157                .collect()
158        }
159    }
160}
161
162/// Substitute the special `"cwd"` token in capability paths with the actual
163/// working directory. Returns absolute paths.
164fn expand_paths(paths: &[String], cwd: &Path) -> Vec<PathBuf> {
165    paths
166        .iter()
167        .map(|p| {
168            if p == "cwd" {
169                cwd.to_path_buf()
170            } else if let Some(stripped) = p.strip_prefix("~/") {
171                if let Some(home) = std::env::var_os("HOME") {
172                    PathBuf::from(home).join(stripped)
173                } else {
174                    PathBuf::from(p)
175                }
176            } else {
177                PathBuf::from(p)
178            }
179        })
180        .collect()
181}
182
183/// Wrap a user-provided command + argv into a `tokio::process::Command` that
184/// is ready to spawn under the resolved sandbox layer. Returns `(Command,
185/// SandboxLayer)` so the caller can record the actual layer in its audit log.
186///
187/// `program` is the binary to run (e.g. `git`, `sh`); `args` are its CLI
188/// arguments. The wrapper may prepend `sandbox-exec -p ...` etc., but the
189/// caller still sees the chosen layer through the returned tuple.
190pub fn wrap_command(
191    program: &str,
192    args: &[String],
193    cwd: &Path,
194    caps: &Capabilities,
195) -> (Command, SandboxLayer) {
196    let layer = resolve_layer(caps);
197    let mut cmd = match layer {
198        SandboxLayer::MacosSandboxExec => {
199            let sbpl = build_macos_sbpl(caps, cwd);
200            let mut c = Command::new("sandbox-exec");
201            c.arg("-p").arg(sbpl).arg(program).args(args);
202            c
203        }
204        // Lightweight / LinuxLandlock / None: spawn the program directly.
205        // (Landlock enforcement, when implemented, applies via a pre-exec
206        // hook in a follow-up; the layer marker is recorded today.)
207        _ => {
208            let mut c = Command::new(program);
209            c.args(args);
210            c
211        }
212    };
213
214    cmd.current_dir(cwd);
215    cmd.env_clear();
216    for (k, v) in scrub_env(&caps.env) {
217        cmd.env(k, v);
218    }
219
220    (cmd, layer)
221}
222
223/// Build a macOS Seatbelt (SBPL) profile string from the declared
224/// capabilities. The profile starts from `(deny default)` and grants
225/// only what `caps` explicitly allows.
226///
227/// References:
228///
229/// - Apple's Seatbelt is undocumented but stable; community references exist
230///   at [chromium.googlesource.com](https://chromium.googlesource.com) and
231///   in macOS's own `/System/Library/Sandbox/Profiles/`.
232/// - Apple has signalled `sandbox-exec` is deprecated. The stable replacement
233///   for our use case (user-controlled child sandboxing) is the Endpoint
234///   Security framework, which is heavyweight and requires entitlements;
235///   that migration is a future track. Until then `sandbox-exec` works.
236pub fn build_macos_sbpl(caps: &Capabilities, cwd: &Path) -> String {
237    let mut out = String::new();
238    out.push_str("(version 1)\n");
239    out.push_str("(deny default)\n");
240    out.push_str("(import \"system.sb\")\n");
241
242    // Process control + signals are needed for any practical child.
243    out.push_str("(allow process-fork process-exec)\n");
244    out.push_str("(allow signal (target self))\n");
245    out.push_str("(allow sysctl-read)\n");
246    out.push_str("(allow mach-lookup)\n");
247    out.push_str("(allow ipc-posix-shm)\n");
248
249    // File reads: explicit paths plus the binary search path. Allow reading
250    // the cwd unconditionally so the program can `stat` itself.
251    out.push_str(&format!(
252        "(allow file-read* (subpath {:?}))\n",
253        cwd.to_string_lossy()
254    ));
255    for p in expand_paths(&caps.read_paths, cwd) {
256        out.push_str(&format!(
257            "(allow file-read* (subpath {:?}))\n",
258            p.to_string_lossy()
259        ));
260    }
261    // Always allow reading shared system bits and the user's PATH.
262    out.push_str("(allow file-read* (subpath \"/usr\"))\n");
263    out.push_str("(allow file-read* (subpath \"/bin\"))\n");
264    out.push_str("(allow file-read* (subpath \"/sbin\"))\n");
265    out.push_str("(allow file-read* (subpath \"/System\"))\n");
266    out.push_str("(allow file-read* (subpath \"/Library\"))\n");
267    out.push_str("(allow file-read* (subpath \"/private/etc\"))\n");
268    out.push_str("(allow file-read* (subpath \"/dev\"))\n");
269
270    // File writes: the workspace (cwd) is always writable - it's the run's
271    // working area - plus any declared paths.
272    out.push_str(&format!(
273        "(allow file-write* (subpath {:?}))\n",
274        cwd.to_string_lossy()
275    ));
276    for p in expand_paths(&caps.write_paths, cwd) {
277        out.push_str(&format!(
278            "(allow file-write* (subpath {:?}))\n",
279            p.to_string_lossy()
280        ));
281    }
282    out.push_str("(allow file-write* (subpath \"/private/tmp\"))\n");
283    out.push_str("(allow file-write* (subpath \"/private/var/folders\"))\n");
284
285    // Network: deny by default unless capability requests it.
286    if caps.net {
287        out.push_str("(allow network*)\n");
288    }
289
290    out
291}
292
293/// Error returned by [`confine_path`] when a candidate path escapes the
294/// workspace root (traversal, absolute path outside the root, or a symlink
295/// pointing out). Carries human-readable detail for the adapter's error.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub struct PathEscape(pub String);
298
299impl std::fmt::Display for PathEscape {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        write!(f, "{}", self.0)
302    }
303}
304
305impl std::error::Error for PathEscape {}
306
307/// Confine `candidate` to within `root`, returning the resolved absolute path
308/// or [`PathEscape`] if it lands outside.
309///
310/// This is the filesystem-jail primitive the `flow-adapter-fs` adapter calls
311/// on **every** read/write/edit/glob/grep so the on-device coding agent can
312/// only touch files inside the user-chosen workspace root.
313///
314/// Resolution rules:
315/// - `candidate` may be absolute or relative; relative paths resolve against
316///   `root`.
317/// - The **root** must exist and is canonicalized (symlinks resolved) so the
318///   prefix check compares real paths.
319/// - For the candidate we canonicalize the **longest existing ancestor** and
320///   re-append the not-yet-existing tail (so writing a new file under the root
321///   is allowed while still resolving symlinks on the existing portion - this
322///   blocks a symlinked parent dir that points outside the root).
323/// - `..` components are normalized away before resolution so plain traversal
324///   (`../../etc/passwd`) is caught even when intermediate dirs don't exist.
325pub fn confine_path(root: &Path, candidate: &Path) -> Result<PathBuf, PathEscape> {
326    let root_canon = root
327        .canonicalize()
328        .map_err(|e| PathEscape(format!("workspace root {} is unusable: {e}", root.display())))?;
329
330    // Resolve the candidate against the root when relative.
331    let joined = if candidate.is_absolute() {
332        candidate.to_path_buf()
333    } else {
334        root_canon.join(candidate)
335    };
336
337    // Normalize `.` and `..` lexically first so traversal is caught even for
338    // paths whose tail doesn't exist yet.
339    let normalized = lexically_normalize(&joined);
340
341    // Canonicalize the longest existing prefix (resolves symlinks on the
342    // existing portion), then re-attach the non-existent tail.
343    let resolved = canonicalize_existing_prefix(&normalized);
344
345    if resolved == root_canon || resolved.starts_with(&root_canon) {
346        Ok(resolved)
347    } else {
348        Err(PathEscape(format!(
349            "path {} escapes workspace root {}",
350            candidate.display(),
351            root.display()
352        )))
353    }
354}
355
356/// Lexically remove `.` and resolve `..` without touching the filesystem.
357/// Leading `..` that would climb above the path root are dropped.
358fn lexically_normalize(p: &Path) -> PathBuf {
359    use std::path::Component;
360    let mut out: Vec<std::ffi::OsString> = Vec::new();
361    let mut prefix_root = PathBuf::new();
362    for comp in p.components() {
363        match comp {
364            Component::Prefix(_) | Component::RootDir => {
365                prefix_root.push(comp.as_os_str());
366            }
367            Component::CurDir => {}
368            Component::ParentDir => {
369                out.pop();
370            }
371            Component::Normal(seg) => out.push(seg.to_os_string()),
372        }
373    }
374    let mut result = prefix_root;
375    for seg in out {
376        result.push(seg);
377    }
378    result
379}
380
381/// Canonicalize the longest existing ancestor of `p` (resolving symlinks on
382/// the existing portion) and re-append the remaining non-existent components.
383fn canonicalize_existing_prefix(p: &Path) -> PathBuf {
384    let mut existing = p.to_path_buf();
385    let mut tail: Vec<std::ffi::OsString> = Vec::new();
386    loop {
387        if let Ok(canon) = existing.canonicalize() {
388            let mut result = canon;
389            for seg in tail.iter().rev() {
390                result.push(seg);
391            }
392            return result;
393        }
394        match existing.file_name() {
395            Some(name) => {
396                tail.push(name.to_os_string());
397                if !existing.pop() {
398                    break;
399                }
400            }
401            None => break,
402        }
403    }
404    // Nothing existed (shouldn't happen since root is canonical); fall back to
405    // the lexical path so the caller's prefix check still runs.
406    p.to_path_buf()
407}
408
409/// Audit-log directory: `~/.flow-studio/logs/audit/`. Created lazily.
410///
411/// Co-located with the app's general tracing logs (see
412/// `apps/frontend/src-tauri/src/logging.rs`) so users have a single
413/// folder to inspect when investigating an issue. Earlier builds wrote to
414/// `~/.flow-studio/audit/` and then `~/flow-studio/Logs/audit/`; neither is
415/// written any more, and stale logs there can be moved or deleted manually.
416pub fn audit_log_path(date_yyyy_mm_dd: &str) -> PathBuf {
417    let home = std::env::var_os("HOME")
418        .map(PathBuf::from)
419        .unwrap_or_else(|| PathBuf::from("."));
420    let dir = home.join(".flow-studio").join("logs").join("audit");
421    let _ = std::fs::create_dir_all(&dir);
422    dir.join(format!("shell-{}.log", date_yyyy_mm_dd))
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use std::path::PathBuf;
429
430    fn caps_with_paths(net: bool, writes: &[&str]) -> Capabilities {
431        Capabilities {
432            net,
433            write_paths: writes.iter().map(|s| s.to_string()).collect(),
434            read_paths: Vec::new(),
435            env: EnvPolicy::Scrubbed,
436        }
437    }
438
439    fn unique_tmp(tag: &str) -> PathBuf {
440        use std::sync::atomic::{AtomicU64, Ordering};
441        static COUNTER: AtomicU64 = AtomicU64::new(0);
442        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
443        std::env::temp_dir().join(format!("flow-{tag}-{}-{n}", std::process::id()))
444    }
445
446    #[test]
447    fn confine_allows_paths_inside_root() {
448        let tmp = unique_tmp("confine");
449        std::fs::create_dir_all(tmp.join("sub")).unwrap();
450        std::fs::write(tmp.join("sub/a.txt"), b"x").unwrap();
451
452        // Existing file inside root.
453        let ok = confine_path(&tmp, Path::new("sub/a.txt")).unwrap();
454        assert!(ok.starts_with(tmp.canonicalize().unwrap()));
455
456        // Not-yet-existing file under an existing dir is allowed (writes).
457        let new_file = confine_path(&tmp, Path::new("sub/new.txt")).unwrap();
458        assert!(new_file.starts_with(tmp.canonicalize().unwrap()));
459        assert!(new_file.ends_with("new.txt"));
460
461        let _ = std::fs::remove_dir_all(&tmp);
462    }
463
464    #[test]
465    fn confine_rejects_traversal_escape() {
466        let tmp = unique_tmp("confine");
467        std::fs::create_dir_all(&tmp).unwrap();
468
469        // Plain traversal out of the root.
470        let err = confine_path(&tmp, Path::new("../../etc/passwd"));
471        assert!(err.is_err(), "traversal must be rejected: {err:?}");
472
473        // Absolute path outside the root.
474        let err2 = confine_path(&tmp, Path::new("/etc/passwd"));
475        assert!(err2.is_err(), "absolute outside must be rejected: {err2:?}");
476
477        let _ = std::fs::remove_dir_all(&tmp);
478    }
479
480    #[test]
481    fn confine_rejects_symlink_escape() {
482        // A symlinked subdir pointing outside the root must not let writes
483        // through it. Skip on platforms without symlink support.
484        #[cfg(unix)]
485        {
486            let tmp = unique_tmp("confine");
487            let outside = unique_tmp("outside");
488            std::fs::create_dir_all(&tmp).unwrap();
489            std::fs::create_dir_all(&outside).unwrap();
490            std::os::unix::fs::symlink(&outside, tmp.join("link")).unwrap();
491
492            let err = confine_path(&tmp, Path::new("link/evil.txt"));
493            assert!(err.is_err(), "symlink escape must be rejected: {err:?}");
494
495            let _ = std::fs::remove_dir_all(&tmp);
496            let _ = std::fs::remove_dir_all(&outside);
497        }
498    }
499
500    #[test]
501    fn env_scrubbed_keeps_safe_vars_only() {
502        std::env::set_var("FLOW_TEST_SECRET", "should-not-leak");
503        std::env::set_var("HOME", "/Users/test");
504        let map = scrub_env(&EnvPolicy::Scrubbed);
505        assert!(map.contains_key("HOME"));
506        assert!(!map.contains_key("FLOW_TEST_SECRET"));
507        std::env::remove_var("FLOW_TEST_SECRET");
508    }
509
510    #[test]
511    fn env_inherit_passes_secrets() {
512        std::env::set_var("FLOW_TEST_INHERIT", "yes");
513        let map = scrub_env(&EnvPolicy::Inherit);
514        assert_eq!(
515            map.get("FLOW_TEST_INHERIT").map(String::as_str),
516            Some("yes")
517        );
518        std::env::remove_var("FLOW_TEST_INHERIT");
519    }
520
521    #[test]
522    fn env_vars_explicit_allow_list() {
523        std::env::set_var("FLOW_TEST_ALLOWED", "yes");
524        std::env::set_var("FLOW_TEST_DENIED", "no");
525        let policy = EnvPolicy::Vars(vec!["FLOW_TEST_ALLOWED".to_string()]);
526        let map = scrub_env(&policy);
527        assert!(map.contains_key("FLOW_TEST_ALLOWED"));
528        assert!(!map.contains_key("FLOW_TEST_DENIED"));
529        std::env::remove_var("FLOW_TEST_ALLOWED");
530        std::env::remove_var("FLOW_TEST_DENIED");
531    }
532
533    #[test]
534    fn resolve_layer_lightweight_when_no_caps() {
535        let caps = Capabilities {
536            net: true,
537            write_paths: Vec::new(),
538            read_paths: Vec::new(),
539            env: EnvPolicy::Scrubbed,
540        };
541        // No write/read paths declared and net=true: nothing for the OS layer
542        // to enforce, so we stay at lightweight.
543        assert_eq!(resolve_layer(&caps), SandboxLayer::Lightweight);
544    }
545
546    #[cfg(target_os = "macos")]
547    #[test]
548    fn resolve_layer_picks_macos_sandbox_exec_when_caps_set() {
549        let caps = caps_with_paths(false, &["cwd"]);
550        assert_eq!(resolve_layer(&caps), SandboxLayer::MacosSandboxExec);
551    }
552
553    #[test]
554    fn build_macos_sbpl_denies_network_when_net_false() {
555        let caps = caps_with_paths(false, &["cwd"]);
556        let cwd = PathBuf::from("/tmp/flow-test");
557        let sbpl = build_macos_sbpl(&caps, &cwd);
558        assert!(sbpl.contains("(deny default)"));
559        assert!(!sbpl.contains("(allow network*)"));
560    }
561
562    #[test]
563    fn build_macos_sbpl_grants_network_when_net_true() {
564        let caps = caps_with_paths(true, &["cwd"]);
565        let cwd = PathBuf::from("/tmp/flow-test");
566        let sbpl = build_macos_sbpl(&caps, &cwd);
567        assert!(sbpl.contains("(allow network*)"));
568    }
569
570    #[test]
571    fn build_macos_sbpl_grants_write_to_declared_paths() {
572        let caps = caps_with_paths(false, &["cwd", "~/.cache"]);
573        let cwd = PathBuf::from("/tmp/flow-test");
574        let sbpl = build_macos_sbpl(&caps, &cwd);
575        assert!(sbpl.contains("/tmp/flow-test"));
576        // The home expansion happens; we just verify a write rule appears.
577        assert!(sbpl.matches("(allow file-write*").count() >= 2);
578    }
579
580    #[test]
581    fn build_macos_sbpl_always_grants_write_to_cwd() {
582        // No declared write_paths: the workspace cwd must still be writable.
583        let caps = caps_with_paths(false, &[]);
584        let cwd = PathBuf::from("/tmp/flow-workspace");
585        let sbpl = build_macos_sbpl(&caps, &cwd);
586        assert!(sbpl.contains("(allow file-write* (subpath \"/tmp/flow-workspace\"))"));
587    }
588
589    #[test]
590    fn audit_log_path_lives_under_flow_studio_audit() {
591        let p = audit_log_path("2026-05-04");
592        let s = p.to_string_lossy();
593        assert!(s.contains(".flow-studio/logs/audit/"));
594        assert!(s.ends_with("shell-2026-05-04.log"));
595    }
596}