Skip to main content

sparrow_config/sandbox/
mod.rs

1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5pub mod backends;
6
7#[cfg(target_os = "linux")]
8mod linux_hardened {
9    // ─── Real local-hardened sandbox (Linux userspace isolation) ────────────────
10    // §3.5: "filesystem allow-list scoped to workspace, network deny by default".
11    //
12    // When `firejail` or `bwrap` is on PATH we wrap the command with it: the
13    // filesystem is scoped to the workspace and the network is severed. When
14    // neither is available we DELEGATE to `LocalSandbox` rather than reaching for
15    // `unshare --root` (which needs CAP_SYS_ADMIN/root and would break exec for
16    // ordinary users). That keeps this backend a strict superset of the previous
17    // default: never weaker, never requiring privileges.
18
19    use super::{Command, ExecResult, FsNetPolicy, Limits, LocalSandbox, Sandbox};
20    use std::path::PathBuf;
21
22    pub struct HardenedSandbox {
23        root: PathBuf,
24        policy: FsNetPolicy,
25        /// Fallback executor + shared per-arg denied-path / workdir enforcement.
26        inner: LocalSandbox,
27    }
28
29    impl HardenedSandbox {
30        pub fn new(root: PathBuf) -> Self {
31            let policy = FsNetPolicy {
32                allowed_paths: vec![root.clone()],
33                allow_network: false,
34                ..FsNetPolicy::default()
35            };
36            let inner = LocalSandbox::new(root.clone()).with_policy(policy.clone());
37            Self {
38                root,
39                policy,
40                inner,
41            }
42        }
43
44        /// Wrap `cmd` with `firejail` if present, scoping the filesystem to the
45        /// workspace and (unless the policy allows it) severing the network.
46        fn firejail(&self, cmd: &Command, limits: &Limits) -> Command {
47            let mut args = vec![
48                "--quiet".to_string(),
49                format!("--timeout={}", (limits.timeout_ms / 1000).max(1)),
50                format!("--private={}", self.root.display()),
51            ];
52            if !self.policy.allow_network {
53                args.push("--net=none".to_string());
54            }
55            for path in &self.policy.allowed_paths {
56                args.push(format!("--whitelist={}", path.display()));
57            }
58            args.push("--".to_string());
59            args.push(cmd.program.clone());
60            args.extend(cmd.args.clone());
61            Command {
62                program: "firejail".to_string(),
63                args,
64                env: cmd.env.clone(),
65                workdir: cmd.workdir.clone(),
66            }
67        }
68
69        /// Wrap `cmd` with `bwrap` (bubblewrap): read-only system dirs, the
70        /// workspace bind-mounted read-write, network unshared by default.
71        fn bwrap(&self, cmd: &Command) -> Command {
72            let root = self.root.display().to_string();
73            let mut args = vec![
74                "--ro-bind".to_string(),
75                "/usr".to_string(),
76                "/usr".to_string(),
77                "--ro-bind".to_string(),
78                "/bin".to_string(),
79                "/bin".to_string(),
80                "--ro-bind".to_string(),
81                "/lib".to_string(),
82                "/lib".to_string(),
83                "--ro-bind-try".to_string(),
84                "/lib64".to_string(),
85                "/lib64".to_string(),
86                "--ro-bind-try".to_string(),
87                "/etc/resolv.conf".to_string(),
88                "/etc/resolv.conf".to_string(),
89                "--proc".to_string(),
90                "/proc".to_string(),
91                "--dev".to_string(),
92                "/dev".to_string(),
93                "--bind".to_string(),
94                root.clone(),
95                root.clone(),
96                "--chdir".to_string(),
97                root,
98            ];
99            if !self.policy.allow_network {
100                args.push("--unshare-net".to_string());
101            }
102            args.push("--".to_string());
103            args.push(cmd.program.clone());
104            args.extend(cmd.args.clone());
105            Command {
106                program: "bwrap".to_string(),
107                args,
108                env: cmd.env.clone(),
109                workdir: cmd.workdir.clone(),
110            }
111        }
112    }
113
114    #[async_trait::async_trait]
115    impl Sandbox for HardenedSandbox {
116        async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
117            // Always route through `inner.exec`, which enforces the workdir-escape
118            // and per-arg denied-path checks and handles the timeout uniformly —
119            // whether we run the raw command or a firejail/bwrap-wrapped one.
120            let effective = if which("firejail") {
121                self.firejail(cmd, limits)
122            } else if which("bwrap") {
123                self.bwrap(cmd)
124            } else {
125                cmd.clone()
126            };
127            self.inner.exec(&effective, limits).await
128        }
129
130        fn root(&self) -> &std::path::Path {
131            &self.root
132        }
133
134        fn policy(&self) -> &FsNetPolicy {
135            &self.policy
136        }
137    }
138
139    fn which(cmd: &str) -> bool {
140        std::process::Command::new("which")
141            .arg(cmd)
142            .output()
143            .map(|o| o.status.success())
144            .unwrap_or(false)
145    }
146}
147
148#[cfg(target_os = "linux")]
149pub use linux_hardened::HardenedSandbox;
150
151#[cfg(not(target_os = "linux"))]
152pub struct HardenedSandbox {
153    _root: PathBuf,
154    _policy: FsNetPolicy,
155}
156
157#[cfg(not(target_os = "linux"))]
158impl HardenedSandbox {
159    pub fn new(root: PathBuf) -> Self {
160        Self {
161            _root: root,
162            _policy: FsNetPolicy::default(),
163        }
164    }
165}
166
167#[cfg(not(target_os = "linux"))]
168#[async_trait::async_trait]
169impl Sandbox for HardenedSandbox {
170    async fn exec(&self, _cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
171        Ok(ExecResult {
172            stdout: String::new(),
173            stderr: "local-hardened sandbox requires Linux (firejail/bwrap/unshare)".into(),
174            exit_code: 127,
175        })
176    }
177
178    fn root(&self) -> &Path {
179        &self._root
180    }
181
182    fn policy(&self) -> &FsNetPolicy {
183        &self._policy
184    }
185}
186
187// ─── Command and limits ─────────────────────────────────────────────────────────
188
189#[derive(Debug, Clone)]
190pub struct Command {
191    pub program: String,
192    pub args: Vec<String>,
193    pub env: HashMap<String, String>,
194    pub workdir: PathBuf,
195}
196
197#[derive(Debug, Clone)]
198pub struct Limits {
199    pub timeout_ms: u64,
200    pub max_output_bytes: usize,
201}
202
203#[derive(Debug, Clone)]
204pub struct ExecResult {
205    pub stdout: String,
206    pub stderr: String,
207    pub exit_code: i32,
208}
209
210// ─── File system and network policy ─────────────────────────────────────────────
211
212#[derive(Debug, Clone)]
213pub struct FsNetPolicy {
214    pub allowed_paths: Vec<PathBuf>,
215    pub allow_network: bool,
216    /// Paths that must never be touched (relative to `root`, matched as prefix).
217    /// Defaults include `.git`, `.env`, `.ssh`, `id_rsa`, `id_ed25519` etc.
218    pub denied_paths: Vec<PathBuf>,
219    /// If non-empty, only env vars whose name appears in this list are forwarded
220    /// to the child process. Empty means "pass through everything explicitly set
221    /// on the Command" (no implicit env stripping).
222    pub env_allowlist: Vec<String>,
223}
224
225impl Default for FsNetPolicy {
226    fn default() -> Self {
227        Self {
228            allowed_paths: vec![],
229            allow_network: false,
230            denied_paths: default_denied_paths(),
231            env_allowlist: Vec::new(),
232        }
233    }
234}
235
236/// The default set of paths that no sandbox is allowed to touch — matched as
237/// path components, so any segment named `.git`, `.env`, `.ssh`, etc. trips the
238/// guard. Kept in sync with `PermissionConfig`'s default denied paths.
239pub fn default_denied_paths() -> Vec<PathBuf> {
240    vec![
241        PathBuf::from(".git"),
242        PathBuf::from(".env"),
243        PathBuf::from(".env.local"),
244        PathBuf::from(".ssh"),
245        PathBuf::from("id_rsa"),
246        PathBuf::from("id_ed25519"),
247    ]
248}
249
250/// True if `path` (after canonicalization fall-back) is inside or equal to any
251/// denied path under `root`, matched by path components rather than substring.
252pub fn path_is_denied(path: &Path, denied: &[PathBuf]) -> bool {
253    let comps: Vec<String> = path
254        .components()
255        .filter_map(|c| match c {
256            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
257            _ => None,
258        })
259        .collect();
260    for d in denied {
261        let d_comps: Vec<String> = d
262            .components()
263            .filter_map(|c| match c {
264                std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
265                _ => None,
266            })
267            .collect();
268        if d_comps.is_empty() {
269            continue;
270        }
271        if comps
272            .windows(d_comps.len())
273            .any(|w| w == d_comps.as_slice())
274        {
275            return true;
276        }
277        if comps.last() == d_comps.last() && d_comps.len() == 1 {
278            return true;
279        }
280    }
281    false
282}
283
284/// Best-effort scan of a *shell command string* (e.g. the argument to `sh -c`)
285/// for references to denied paths, returning the offending token if found.
286///
287/// IMPORTANT — this is defence-in-depth, NOT isolation. A `sh -c "<string>"`
288/// invocation can read anything the process user can via globs (`.s*h`), shell
289/// expansion (`$HOME/.ssh`), here-docs, or an alternate reader, none of which
290/// this catches. It exists to stop the obvious, literal `cat ~/.ssh/id_rsa`
291/// class of accidents/prompt-injections; for real confinement use the
292/// `local-hardened` (Linux namespaces) or `docker`/`ssh` sandbox backends.
293///
294/// We tokenise on shell metacharacters and whitespace, strip quotes, and run
295/// each path-shaped token through [`path_is_denied`].
296pub fn command_touches_denied_path(cmd: &str, denied: &[PathBuf]) -> Option<String> {
297    if denied.is_empty() {
298        return None;
299    }
300    let is_sep = |c: char| {
301        c.is_whitespace()
302            || matches!(
303                c,
304                ';' | '|' | '&' | '<' | '>' | '(' | ')' | '{' | '}' | '`' | '"' | '\'' | '=' | ','
305            )
306    };
307    for raw in cmd.split(is_sep) {
308        let token = raw.trim_matches(|c| matches!(c, '"' | '\'' | '`'));
309        if token.is_empty() {
310            continue;
311        }
312        // Only bother with tokens that look like a path or a bare sensitive name.
313        let path_shaped = token.contains('/') || token.contains('\\') || token.starts_with('.');
314        if !path_shaped && !token.contains("id_") {
315            continue;
316        }
317        if path_is_denied(Path::new(token), denied) {
318            return Some(token.to_string());
319        }
320    }
321    None
322}
323
324// ─── THE SANDBOX TRAIT ──────────────────────────────────────────────────────────
325
326/// Isolates `exec`/`Mutating` actions. Backends are selectable per run.
327#[async_trait]
328pub trait Sandbox: Send + Sync {
329    async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult>;
330    fn root(&self) -> &Path;
331    fn policy(&self) -> &FsNetPolicy;
332}
333
334// ─── Local sandbox implementation ───────────────────────────────────────────────
335
336pub struct LocalSandbox {
337    root: PathBuf,
338    policy: FsNetPolicy,
339}
340
341impl LocalSandbox {
342    pub fn new(root: PathBuf) -> Self {
343        Self {
344            root: root.clone(),
345            policy: FsNetPolicy {
346                allowed_paths: vec![root],
347                allow_network: true,
348                ..FsNetPolicy::default()
349            },
350        }
351    }
352
353    pub fn hardened(root: PathBuf) -> Self {
354        Self {
355            root: root.clone(),
356            policy: FsNetPolicy {
357                allowed_paths: vec![root],
358                allow_network: false, // deny by default for hardened
359                ..FsNetPolicy::default()
360            },
361        }
362    }
363
364    pub fn with_policy(mut self, policy: FsNetPolicy) -> Self {
365        self.policy = policy;
366        self
367    }
368}
369
370#[async_trait]
371impl Sandbox for LocalSandbox {
372    async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
373        use std::process::Command as StdCommand;
374        use std::time::Instant;
375
376        let root = self
377            .root
378            .canonicalize()
379            .unwrap_or_else(|_| self.root.clone());
380        let workdir = cmd
381            .workdir
382            .canonicalize()
383            .unwrap_or_else(|_| cmd.workdir.clone());
384        if !workdir.starts_with(&root) {
385            anyhow::bail!(
386                "Command workdir escapes sandbox root: {}",
387                cmd.workdir.display()
388            );
389        }
390
391        if path_is_denied(&workdir, &self.policy.denied_paths) {
392            anyhow::bail!(
393                "Command workdir hits a protected path: {}",
394                cmd.workdir.display()
395            );
396        }
397        for arg in &cmd.args {
398            let p = Path::new(arg);
399            if path_is_denied(p, &self.policy.denied_paths) {
400                anyhow::bail!("Command argument refers to a protected path: {}", arg);
401            }
402        }
403
404        let env: HashMap<String, String> = if self.policy.env_allowlist.is_empty() {
405            cmd.env.clone()
406        } else {
407            cmd.env
408                .iter()
409                .filter(|(k, _)| self.policy.env_allowlist.iter().any(|a| a == *k))
410                .map(|(k, v)| (k.clone(), v.clone()))
411                .collect()
412        };
413
414        let mut builder = StdCommand::new(&cmd.program);
415        builder
416            .args(&cmd.args)
417            .current_dir(&workdir)
418            .stdout(std::process::Stdio::piped())
419            .stderr(std::process::Stdio::piped());
420        if !self.policy.env_allowlist.is_empty() {
421            builder.env_clear();
422        }
423        builder.envs(&env);
424        let mut child = builder.spawn()?;
425
426        let start = Instant::now();
427        let timeout = std::time::Duration::from_millis(limits.timeout_ms);
428
429        // Simple timeout via polling
430        loop {
431            match child.try_wait()? {
432                Some(status) => {
433                    let output = child.wait_with_output()?;
434                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
435                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
436                    let exit_code = status.code().unwrap_or(-1);
437
438                    return Ok(ExecResult {
439                        stdout: truncate(stdout, limits.max_output_bytes),
440                        stderr: truncate(stderr, limits.max_output_bytes),
441                        exit_code,
442                    });
443                }
444                None => {
445                    if start.elapsed() > timeout {
446                        let _ = child.kill();
447                        return Ok(ExecResult {
448                            stdout: String::new(),
449                            stderr: "TIMEOUT".to_string(),
450                            exit_code: -1,
451                        });
452                    }
453                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
454                }
455            }
456        }
457    }
458
459    fn root(&self) -> &Path {
460        &self.root
461    }
462
463    fn policy(&self) -> &FsNetPolicy {
464        &self.policy
465    }
466}
467
468fn truncate(s: String, max_bytes: usize) -> String {
469    if s.len() <= max_bytes {
470        s
471    } else {
472        let truncate_at = max_bytes.saturating_sub(100);
473        format!(
474            "{}\n... [truncated, {} bytes total]",
475            &s[..truncate_at.min(s.len())],
476            s.len()
477        )
478    }
479}
480
481#[cfg(test)]
482mod denied_path_tests {
483    use super::{command_touches_denied_path, default_denied_paths, path_is_denied};
484    use std::path::{Path, PathBuf};
485
486    #[test]
487    fn path_is_denied_matches_components_not_substrings() {
488        let denied = default_denied_paths();
489        assert!(path_is_denied(Path::new("/home/u/.ssh/id_rsa"), &denied));
490        assert!(path_is_denied(Path::new("project/.env"), &denied));
491        assert!(path_is_denied(Path::new("id_ed25519"), &denied));
492        // `.environment` must NOT trip the `.env` rule (component, not prefix).
493        assert!(!path_is_denied(
494            Path::new("src/.environment/notes"),
495            &denied
496        ));
497        assert!(!path_is_denied(Path::new("src/main.rs"), &denied));
498    }
499
500    #[test]
501    fn command_guard_catches_literal_secret_reads() {
502        let denied = default_denied_paths();
503        assert!(command_touches_denied_path("cat ~/.ssh/id_rsa", &denied).is_some());
504        assert!(command_touches_denied_path("cat /home/u/.ssh/id_rsa", &denied).is_some());
505        assert!(command_touches_denied_path("cp .env /tmp/x", &denied).is_some());
506        assert!(command_touches_denied_path("echo hi > project/.git/hooks/x", &denied).is_some());
507        // quoted / piped variants still tokenise
508        assert!(command_touches_denied_path("tar c '.ssh' | nc x 1", &denied).is_some());
509    }
510
511    #[test]
512    fn command_guard_allows_benign_commands() {
513        let denied = default_denied_paths();
514        assert!(command_touches_denied_path("cargo test --all", &denied).is_none());
515        assert!(command_touches_denied_path("ls -la src/", &denied).is_none());
516        assert!(command_touches_denied_path("grep -r TODO crates/", &denied).is_none());
517    }
518
519    #[test]
520    fn command_guard_empty_denylist_is_noop() {
521        assert!(command_touches_denied_path("cat ~/.ssh/id_rsa", &[] as &[PathBuf]).is_none());
522    }
523}
524
525// ─── Linux HardenedSandbox wiring (#10b) ─────────────────────────────────────
526// These run only on Linux. With firejail/bwrap installed (the CI `sandbox-linux`
527// job installs bubblewrap) the command is wrapped with FS scoped to the
528// workspace and network denied; without either tool it falls back to the
529// in-process LocalSandbox — so the test verifies the wiring either way and never
530// requires root. This is the path that could not be compiled on the Windows dev
531// host; CI is its home.
532#[cfg(all(test, target_os = "linux"))]
533mod hardened_linux_tests {
534    use super::{Command, HardenedSandbox, Limits, Sandbox};
535    use std::collections::HashMap;
536
537    fn limits() -> Limits {
538        Limits {
539            timeout_ms: 10_000,
540            max_output_bytes: 64 * 1024,
541        }
542    }
543
544    #[tokio::test]
545    async fn hardened_sandbox_runs_a_command_in_the_workspace() {
546        let dir = tempfile::tempdir().unwrap();
547        let root = dir.path().to_path_buf();
548        let sandbox = HardenedSandbox::new(root.clone());
549
550        // Policy contract: workspace is the only allowed path, network denied.
551        assert!(!sandbox.policy().allow_network);
552        assert_eq!(sandbox.root(), root.as_path());
553
554        let cmd = Command {
555            program: "sh".into(),
556            args: vec!["-c".into(), "echo sparrow-ok".into()],
557            env: HashMap::new(),
558            workdir: root.clone(),
559        };
560        let result = sandbox.exec(&cmd, &limits()).await.expect("exec");
561        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
562        assert!(
563            result.stdout.contains("sparrow-ok"),
564            "stdout was: {:?}",
565            result.stdout
566        );
567    }
568
569    #[tokio::test]
570    async fn hardened_sandbox_rejects_workdir_escape() {
571        let dir = tempfile::tempdir().unwrap();
572        let sandbox = HardenedSandbox::new(dir.path().to_path_buf());
573        let cmd = Command {
574            program: "sh".into(),
575            args: vec!["-c".into(), "echo nope".into()],
576            env: HashMap::new(),
577            workdir: std::path::PathBuf::from("/etc"), // outside the workspace root
578        };
579        // Routed through the inner LocalSandbox, which bails on a workdir escape.
580        assert!(sandbox.exec(&cmd, &limits()).await.is_err());
581    }
582}