Skip to main content

sparrow/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 namespaces + seccomp) ───────────────
10    // §3.5: "Linux namespaces + seccomp (landlock/seccompiler),
11    //        filesystem allow-list scoped to workspace, network deny by default"
12
13    use super::{Command, ExecResult, FsNetPolicy, Limits, Sandbox};
14    use std::path::PathBuf;
15
16    pub struct HardenedSandbox {
17        root: PathBuf,
18        policy: FsNetPolicy,
19    }
20
21    impl HardenedSandbox {
22        pub fn new(root: PathBuf) -> Self {
23            Self {
24                root: root.clone(),
25                policy: FsNetPolicy {
26                    allowed_paths: vec![root],
27                    allow_network: false,
28                    ..FsNetPolicy::default()
29                },
30            }
31        }
32    }
33
34    #[async_trait::async_trait]
35    impl Sandbox for HardenedSandbox {
36        async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
37            use std::process::Command as StdCommand;
38
39            // Build a firejail/bwrap command if available, else fall back to local
40            let (program, args) = if which("firejail") {
41                let mut fargs = vec![
42                    "--quiet".into(),
43                    format!("--timeout={}", limits.timeout_ms / 1000),
44                    format!("--private={}", self.root.display()),
45                ];
46                if !self.policy.allow_network {
47                    fargs.push("--net=none".into());
48                }
49                for path in &self.policy.allowed_paths {
50                    fargs.push(format!("--whitelist={}", path.display()));
51                }
52                fargs.push("--".into());
53                fargs.push(cmd.program.clone());
54                fargs.extend(cmd.args.clone());
55                ("firejail".to_string(), fargs)
56            } else if which("bwrap") {
57                let mut bargs = vec![
58                    "--ro-bind".into(),
59                    "/usr".into(),
60                    "/usr".into(),
61                    "--ro-bind".into(),
62                    "/lib".into(),
63                    "/lib".into(),
64                    "--ro-bind".into(),
65                    "/lib64".into(),
66                    "/lib64".into(),
67                    "--ro-bind".into(),
68                    "/bin".into(),
69                    "/bin".into(),
70                    "--bind".into(),
71                    self.root.display().to_string(),
72                    self.root.display().to_string(),
73                    "--chdir".into(),
74                    self.root.display().to_string(),
75                ];
76                if !self.policy.allow_network {
77                    bargs.push("--unshare-net".into());
78                }
79                bargs.push("--".into());
80                bargs.push(cmd.program.clone());
81                bargs.extend(cmd.args.clone());
82                ("bwrap".to_string(), bargs)
83            } else {
84                // Fallback: unshare with basic isolation
85                let mut uargs = vec![
86                    "--mount".into(),
87                    "--pid".into(),
88                    "--fork".into(),
89                    "--root".into(),
90                    self.root.display().to_string(),
91                ];
92                uargs.push(cmd.program.clone());
93                uargs.extend(cmd.args.clone());
94                ("unshare".to_string(), uargs)
95            };
96
97            let output = StdCommand::new(&program)
98                .args(&args)
99                .current_dir(&cmd.workdir)
100                .output()?;
101
102            Ok(ExecResult {
103                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
104                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
105                exit_code: output.status.code().unwrap_or(-1),
106            })
107        }
108
109        fn root(&self) -> &std::path::Path {
110            &self.root
111        }
112
113        fn policy(&self) -> &FsNetPolicy {
114            &self.policy
115        }
116    }
117
118    fn which(cmd: &str) -> bool {
119        std::process::Command::new("which")
120            .arg(cmd)
121            .output()
122            .map(|o| o.status.success())
123            .unwrap_or(false)
124    }
125}
126
127#[cfg(target_os = "linux")]
128pub use linux_hardened::HardenedSandbox;
129
130#[cfg(not(target_os = "linux"))]
131pub struct HardenedSandbox {
132    _root: PathBuf,
133    _policy: FsNetPolicy,
134}
135
136#[cfg(not(target_os = "linux"))]
137impl HardenedSandbox {
138    pub fn new(root: PathBuf) -> Self {
139        Self {
140            _root: root,
141            _policy: FsNetPolicy::default(),
142        }
143    }
144}
145
146#[cfg(not(target_os = "linux"))]
147#[async_trait::async_trait]
148impl Sandbox for HardenedSandbox {
149    async fn exec(&self, _cmd: &Command, _limits: &Limits) -> anyhow::Result<ExecResult> {
150        Ok(ExecResult {
151            stdout: String::new(),
152            stderr: "local-hardened sandbox requires Linux (firejail/bwrap/unshare)".into(),
153            exit_code: 127,
154        })
155    }
156
157    fn root(&self) -> &Path {
158        &self._root
159    }
160
161    fn policy(&self) -> &FsNetPolicy {
162        &self._policy
163    }
164}
165
166// ─── Command and limits ─────────────────────────────────────────────────────────
167
168#[derive(Debug, Clone)]
169pub struct Command {
170    pub program: String,
171    pub args: Vec<String>,
172    pub env: HashMap<String, String>,
173    pub workdir: PathBuf,
174}
175
176#[derive(Debug, Clone)]
177pub struct Limits {
178    pub timeout_ms: u64,
179    pub max_output_bytes: usize,
180}
181
182#[derive(Debug, Clone)]
183pub struct ExecResult {
184    pub stdout: String,
185    pub stderr: String,
186    pub exit_code: i32,
187}
188
189// ─── File system and network policy ─────────────────────────────────────────────
190
191#[derive(Debug, Clone)]
192pub struct FsNetPolicy {
193    pub allowed_paths: Vec<PathBuf>,
194    pub allow_network: bool,
195    /// Paths that must never be touched (relative to `root`, matched as prefix).
196    /// Defaults include `.git`, `.env`, `.ssh`, `id_rsa`, `id_ed25519` etc.
197    pub denied_paths: Vec<PathBuf>,
198    /// If non-empty, only env vars whose name appears in this list are forwarded
199    /// to the child process. Empty means "pass through everything explicitly set
200    /// on the Command" (no implicit env stripping).
201    pub env_allowlist: Vec<String>,
202}
203
204impl Default for FsNetPolicy {
205    fn default() -> Self {
206        Self {
207            allowed_paths: vec![],
208            allow_network: false,
209            denied_paths: default_denied_paths(),
210            env_allowlist: Vec::new(),
211        }
212    }
213}
214
215/// The default set of paths that no sandbox is allowed to touch — matched as
216/// path components, so any segment named `.git`, `.env`, `.ssh`, etc. trips the
217/// guard. Kept in sync with `PermissionConfig`'s default denied paths.
218pub fn default_denied_paths() -> Vec<PathBuf> {
219    vec![
220        PathBuf::from(".git"),
221        PathBuf::from(".env"),
222        PathBuf::from(".env.local"),
223        PathBuf::from(".ssh"),
224        PathBuf::from("id_rsa"),
225        PathBuf::from("id_ed25519"),
226    ]
227}
228
229/// True if `path` (after canonicalization fall-back) is inside or equal to any
230/// denied path under `root`, matched by path components rather than substring.
231pub fn path_is_denied(path: &Path, denied: &[PathBuf]) -> bool {
232    let comps: Vec<String> = path
233        .components()
234        .filter_map(|c| match c {
235            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
236            _ => None,
237        })
238        .collect();
239    for d in denied {
240        let d_comps: Vec<String> = d
241            .components()
242            .filter_map(|c| match c {
243                std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
244                _ => None,
245            })
246            .collect();
247        if d_comps.is_empty() {
248            continue;
249        }
250        if comps
251            .windows(d_comps.len())
252            .any(|w| w == d_comps.as_slice())
253        {
254            return true;
255        }
256        if comps.last() == d_comps.last() && d_comps.len() == 1 {
257            return true;
258        }
259    }
260    false
261}
262
263// ─── THE SANDBOX TRAIT ──────────────────────────────────────────────────────────
264
265/// Isolates `exec`/`Mutating` actions. Backends are selectable per run.
266#[async_trait]
267pub trait Sandbox: Send + Sync {
268    async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult>;
269    fn root(&self) -> &Path;
270    fn policy(&self) -> &FsNetPolicy;
271}
272
273// ─── Local sandbox implementation ───────────────────────────────────────────────
274
275pub struct LocalSandbox {
276    root: PathBuf,
277    policy: FsNetPolicy,
278}
279
280impl LocalSandbox {
281    pub fn new(root: PathBuf) -> Self {
282        Self {
283            root: root.clone(),
284            policy: FsNetPolicy {
285                allowed_paths: vec![root],
286                allow_network: true,
287                ..FsNetPolicy::default()
288            },
289        }
290    }
291
292    pub fn hardened(root: PathBuf) -> Self {
293        Self {
294            root: root.clone(),
295            policy: FsNetPolicy {
296                allowed_paths: vec![root],
297                allow_network: false, // deny by default for hardened
298                ..FsNetPolicy::default()
299            },
300        }
301    }
302
303    pub fn with_policy(mut self, policy: FsNetPolicy) -> Self {
304        self.policy = policy;
305        self
306    }
307}
308
309#[async_trait]
310impl Sandbox for LocalSandbox {
311    async fn exec(&self, cmd: &Command, limits: &Limits) -> anyhow::Result<ExecResult> {
312        use std::process::Command as StdCommand;
313        use std::time::Instant;
314
315        let root = self
316            .root
317            .canonicalize()
318            .unwrap_or_else(|_| self.root.clone());
319        let workdir = cmd
320            .workdir
321            .canonicalize()
322            .unwrap_or_else(|_| cmd.workdir.clone());
323        if !workdir.starts_with(&root) {
324            anyhow::bail!(
325                "Command workdir escapes sandbox root: {}",
326                cmd.workdir.display()
327            );
328        }
329
330        if path_is_denied(&workdir, &self.policy.denied_paths) {
331            anyhow::bail!(
332                "Command workdir hits a protected path: {}",
333                cmd.workdir.display()
334            );
335        }
336        for arg in &cmd.args {
337            let p = Path::new(arg);
338            if path_is_denied(p, &self.policy.denied_paths) {
339                anyhow::bail!("Command argument refers to a protected path: {}", arg);
340            }
341        }
342
343        let env: HashMap<String, String> = if self.policy.env_allowlist.is_empty() {
344            cmd.env.clone()
345        } else {
346            cmd.env
347                .iter()
348                .filter(|(k, _)| self.policy.env_allowlist.iter().any(|a| a == *k))
349                .map(|(k, v)| (k.clone(), v.clone()))
350                .collect()
351        };
352
353        let mut builder = StdCommand::new(&cmd.program);
354        builder
355            .args(&cmd.args)
356            .current_dir(&workdir)
357            .stdout(std::process::Stdio::piped())
358            .stderr(std::process::Stdio::piped());
359        if !self.policy.env_allowlist.is_empty() {
360            builder.env_clear();
361        }
362        builder.envs(&env);
363        let mut child = builder.spawn()?;
364
365        let start = Instant::now();
366        let timeout = std::time::Duration::from_millis(limits.timeout_ms);
367
368        // Simple timeout via polling
369        loop {
370            match child.try_wait()? {
371                Some(status) => {
372                    let output = child.wait_with_output()?;
373                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
374                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
375                    let exit_code = status.code().unwrap_or(-1);
376
377                    return Ok(ExecResult {
378                        stdout: truncate(stdout, limits.max_output_bytes),
379                        stderr: truncate(stderr, limits.max_output_bytes),
380                        exit_code,
381                    });
382                }
383                None => {
384                    if start.elapsed() > timeout {
385                        let _ = child.kill();
386                        return Ok(ExecResult {
387                            stdout: String::new(),
388                            stderr: "TIMEOUT".to_string(),
389                            exit_code: -1,
390                        });
391                    }
392                    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
393                }
394            }
395        }
396    }
397
398    fn root(&self) -> &Path {
399        &self.root
400    }
401
402    fn policy(&self) -> &FsNetPolicy {
403        &self.policy
404    }
405}
406
407fn truncate(s: String, max_bytes: usize) -> String {
408    if s.len() <= max_bytes {
409        s
410    } else {
411        let truncate_at = max_bytes.saturating_sub(100);
412        format!(
413            "{}\n... [truncated, {} bytes total]",
414            &s[..truncate_at.min(s.len())],
415            s.len()
416        )
417    }
418}