Skip to main content

improv_extfn/
sandbox.rs

1//! OS sandbox for the subprocess runners (Python/R/Julia/Pure).
2//!
3//! The Wasm runtime (see `wasm.rs`) is already sandboxed by construction: it
4//! runs in-process via the `wasmi` interpreter, which has no syscall access
5//! and no host imports linked, so untrusted Wasm bodies cannot touch the
6//! filesystem or network regardless of OS (fuel and a 64 MiB memory cap bound
7//! its CPU and memory; see `wasm.rs`). This module hardens the
8//! *subprocess* runtimes instead, which spawn a real OS process (`python3`,
9//! `Rscript`, `julia`, `pure`) that would otherwise have the full permissions
10//! of the Improv process.
11//!
12//! # Policy
13//!
14//! The host decides, never the model: [`SandboxPolicy::Restricted`] applies to
15//! every external function unless the operator sets `IMPROV_EXTFN_TRUSTED=1`,
16//! which selects [`SandboxPolicy::Trusted`] (no sandbox) for the whole
17//! process. See [`policy_for`].
18//!
19//! # What's enforced, by platform
20//!
21//! * **Linux**: the interpreter is re-exec'd under `bwrap` (bubblewrap), an
22//!   unprivileged filesystem/network sandbox: only `/usr`, `/bin`, `/lib`,
23//!   `/lib64` and `/etc` are visible (read-only); `/tmp`, `/home` and `/root`
24//!   are empty tmpfs, so the host `$HOME` and CWD are invisible; every
25//!   namespace is unshared (no network); the environment is cleared to
26//!   `PATH=/usr/bin:/bin`, `HOME=/tmp` and `LANG`. `setrlimit` ceilings (CPU
27//!   time, address space, open files, process count) are applied via
28//!   `pre_exec`, alongside the wall-clock process-group kill in `runner.rs`.
29//! * **macOS / Windows**: there is no `bwrap`, so `Restricted` refuses to run
30//!   (see below). With the opt-out set, macOS gets the `setrlimit` ceilings
31//!   and Windows gets only the wall-clock timeout (a Job Object limiter is
32//!   future work).
33//!
34//! # Fail closed
35//!
36//! `Restricted` without `bwrap` returns [`ExtFnError::Sandbox`] instead of
37//! running the body unconfined, unless the operator sets
38//! `IMPROV_EXTFN_ALLOW_UNSANDBOXED=1`, which falls back to `setrlimit` only.
39//! A `setrlimit` failure in `pre_exec` aborts the spawn. A limit an ancestor
40//! already lowered is kept (the ceiling is clamped to the existing hard limit,
41//! never raised).
42
43use crate::ExtFnError;
44use std::path::{Path, PathBuf};
45use std::process::Command;
46use std::sync::OnceLock;
47
48/// The refusal returned under `Restricted` when `bwrap` is missing.
49const NO_BWRAP: &str =
50    "bwrap not found; install bubblewrap or set IMPROV_EXTFN_ALLOW_UNSANDBOXED=1";
51
52/// How strictly a subprocess runner should be confined.
53///
54/// `Restricted` is the default for every body. `Trusted` skips sandboxing and
55/// only the operator selects it (`IMPROV_EXTFN_TRUSTED=1`, see `policy_for`).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum SandboxPolicy {
58    /// No sandboxing applied; the interpreter runs with the caller's full
59    /// filesystem/network/resource access (still subject to the existing
60    /// wall-clock timeout in `runner.rs`).
61    Trusted,
62    /// `bwrap` + rlimits; refuses to run without `bwrap` unless the operator
63    /// sets `IMPROV_EXTFN_ALLOW_UNSANDBOXED=1` (then rlimits only).
64    #[default]
65    Restricted,
66}
67
68/// The policy for every subprocess runner. The **host** decides, never the
69/// model: `Restricted` unless the operator sets `IMPROV_EXTFN_TRUSTED=1`
70/// (read once per process). [`ExternalFn::pure`](crate::ExternalFn::pure) is
71/// model data, so it keeps only its caching meaning; letting it choose the
72/// sandbox would let a shared model opt itself out.
73pub fn policy_for() -> SandboxPolicy {
74    static POLICY: OnceLock<SandboxPolicy> = OnceLock::new();
75    *POLICY.get_or_init(|| policy_from_env(std::env::var_os("IMPROV_EXTFN_TRUSTED").as_deref()))
76}
77
78/// `IMPROV_EXTFN_TRUSTED` value to policy: exactly `1` means `Trusted`.
79fn policy_from_env(trusted: Option<&std::ffi::OsStr>) -> SandboxPolicy {
80    if trusted.is_some_and(|v| v == "1") {
81        SandboxPolicy::Trusted
82    } else {
83        SandboxPolicy::Restricted
84    }
85}
86
87/// `IMPROV_EXTFN_ALLOW_UNSANDBOXED=1`: the operator accepts running
88/// `Restricted` bodies with rlimits only when `bwrap` is missing.
89fn allow_unsandboxed() -> bool {
90    static ALLOW: OnceLock<bool> = OnceLock::new();
91    *ALLOW.get_or_init(|| {
92        std::env::var_os("IMPROV_EXTFN_ALLOW_UNSANDBOXED").is_some_and(|v| v == "1")
93    })
94}
95
96/// Resource ceilings applied via `setrlimit` under [`SandboxPolicy::Restricted`].
97/// Generous enough not to break legitimate small computations, tight enough to
98/// blunt runaway memory growth and fork bombs.
99#[cfg(unix)]
100mod limits {
101    /// CPU time, seconds. Defense-in-depth alongside the wall-clock kill in
102    /// `runner.rs` (which also bounds wall time even for CPU-light but
103    /// I/O-blocked processes).
104    pub const CPU_SECONDS: u64 = 30;
105    /// Address space, bytes (1 GiB). `RLIMIT_AS` bounds *virtual* memory,
106    /// which for CPython/R/Julia's allocators is a reasonable proxy for RSS
107    /// without needing cgroups.
108    pub const ADDRESS_SPACE_BYTES: u64 = 1 << 30;
109    /// Max open file descriptors.
110    pub const MAX_FDS: u64 = 256;
111    /// Max processes/threads for this (real) uid. `RLIMIT_NPROC` is scoped to
112    /// the whole uid system-wide, not the sandboxed process's own subtree, so
113    /// this must stay well above however many processes/threads the *host*
114    /// already owns (a dev machine or CI runner commonly has hundreds), not
115    /// just above what one interpreter needs — a low ceiling here doesn't
116    /// just blunt the user's fork bomb, it can make `bwrap`'s own namespace
117    /// setup (or Python's threads) fail with EAGAIN before the user body even
118    /// runs. 4096 is comfortably above typical host process counts while still
119    /// bounding a runaway fork loop (which would hit thousands of processes
120    /// within its first second).
121    pub const MAX_NPROC: u64 = 4096;
122}
123
124/// Apply `policy` to `cmd` before it is spawned: rewrite it as
125/// `bwrap <sandbox args> -- <original cmd/args>` with a `pre_exec` rlimit
126/// hook. Without `bwrap` this fails closed (see the module docs). `Trusted`
127/// is a no-op.
128///
129/// Called from [`crate::runner::run_interpreter`] so every subprocess runner
130/// (Python/R/Julia/Pure) is covered without per-language duplication.
131pub fn apply(cmd: Command, policy: SandboxPolicy) -> Result<Command, ExtFnError> {
132    apply_with(cmd, policy, bwrap_path(), allow_unsandboxed())
133}
134
135/// [`apply`] with the `bwrap` lookup and the opt-out injected (for tests).
136fn apply_with(
137    cmd: Command,
138    policy: SandboxPolicy,
139    bwrap: Option<PathBuf>,
140    allow_unsandboxed: bool,
141) -> Result<Command, ExtFnError> {
142    if policy == SandboxPolicy::Trusted {
143        return Ok(cmd);
144    }
145    match bwrap {
146        Some(bwrap) => Ok(with_rlimits(scrub_env(wrap_bwrap(&bwrap, &cmd)))),
147        None if allow_unsandboxed => Ok(with_rlimits(scrub_env(cmd))),
148        None => Err(ExtFnError::Sandbox(NO_BWRAP.into())),
149    }
150}
151
152/// `bwrap` on `PATH`. Bubblewrap is Linux-only, so `None` elsewhere.
153fn bwrap_path() -> Option<PathBuf> {
154    if cfg!(target_os = "linux") {
155        which("bwrap")
156    } else {
157        None
158    }
159}
160
161/// The only environment a `Restricted` child sees: no tokens, cloud
162/// credentials or database URLs from the host (S6).
163const SANDBOX_PATH: &str = "/usr/bin:/bin";
164const SANDBOX_HOME: &str = "/tmp";
165
166/// Replace the inherited environment with `PATH`, `HOME` and (if set) `LANG`.
167fn scrub_env(mut cmd: Command) -> Command {
168    cmd.env_clear()
169        .env("PATH", SANDBOX_PATH)
170        .env("HOME", SANDBOX_HOME);
171    if let Some(lang) = std::env::var_os("LANG") {
172        cmd.env("LANG", lang);
173    }
174    cmd
175}
176
177/// Rebuild `cmd` as `bwrap <sandbox flags> -- <original program> <args>`.
178///
179/// The program is resolved on the host `PATH` first and run by absolute path:
180/// inside the sandbox `PATH` is only [`SANDBOX_PATH`], which on non-FHS hosts
181/// (NixOS, where the interpreter lives under `/nix/store`) holds no
182/// interpreter at all. On such hosts `/nix/store` is bound read-only too. It
183/// is world-readable, immutable, and holds no secrets.
184fn wrap_bwrap(bwrap: &Path, cmd: &Command) -> Command {
185    let mut wrapped = Command::new(bwrap);
186    wrapped.args(BWRAP_ARGS);
187    if Path::new(NIX_STORE).is_dir() {
188        wrapped.args(["--ro-bind", NIX_STORE, NIX_STORE]);
189    }
190    if let Some(lang) = std::env::var_os("LANG") {
191        wrapped.arg("--setenv").arg("LANG").arg(lang);
192    }
193    wrapped.arg("--");
194    let program = Path::new(cmd.get_program());
195    // Canonicalized, so a symlink chain (`~/.nix-profile/bin/python3`, a
196    // pyenv shim) resolves to the real binary under a bound directory.
197    let resolved = match program.is_absolute() {
198        true => Some(program.to_path_buf()),
199        false => which(&program.to_string_lossy()),
200    }
201    .and_then(|p| std::fs::canonicalize(p).ok())
202    .unwrap_or_else(|| program.into());
203    wrapped.arg(resolved);
204    wrapped.args(cmd.get_args());
205    wrapped
206}
207
208/// The Nix store, bound read-only into the sandbox when the host has one.
209const NIX_STORE: &str = "/nix/store";
210
211/// Unprivileged bubblewrap sandbox. The new root is empty except for the
212/// system directories the interpreter needs, bound read-only (`/usr`, plus
213/// `/bin`, `/lib`, `/lib64` and `/etc` where they exist). `/tmp`, `/home` and
214/// `/root` are fresh empty tmpfs, so the host's `$HOME` (ssh keys, `~/.aws`,
215/// cargo credentials) and CWD are invisible. Every namespace is unshared (no
216/// network), the environment is cleared, and the sandbox dies with Improv.
217#[rustfmt::skip]
218const BWRAP_ARGS: &[&str] = &[
219    "--ro-bind", "/usr", "/usr",
220    "--ro-bind-try", "/bin", "/bin",
221    "--ro-bind-try", "/lib", "/lib",
222    "--ro-bind-try", "/lib64", "/lib64",
223    "--ro-bind-try", "/etc", "/etc",
224    "--tmpfs", "/tmp",
225    "--tmpfs", "/home",
226    "--tmpfs", "/root",
227    "--dev", "/dev",
228    "--proc", "/proc",
229    "--unshare-all",
230    "--die-with-parent",
231    "--clearenv",
232    "--setenv", "PATH", SANDBOX_PATH,
233    "--setenv", "HOME", SANDBOX_HOME,
234];
235
236/// Search `PATH` for `name`, the same way the shell would. `std::process`
237/// doesn't expose this, so it's a small manual walk.
238fn which(name: &str) -> Option<std::path::PathBuf> {
239    let path = std::env::var_os("PATH")?;
240    std::env::split_paths(&path).find_map(|dir| {
241        let candidate = dir.join(name);
242        candidate.is_file().then_some(candidate)
243    })
244}
245
246/// libc's raw resource-id type: `c_int` on macOS/BSD, `u32` on Linux (glibc).
247/// A type alias lets [`set_rlimit`] take whichever `libc::RLIMIT_*` constants
248/// resolve to on the target, without a manual cast at each of its call sites.
249#[cfg(target_os = "linux")]
250type RlimitResource = u32;
251#[cfg(all(unix, not(target_os = "linux")))]
252type RlimitResource = libc::c_int;
253
254/// Attach a `pre_exec` hook that applies the `setrlimit` ceilings. Any
255/// failure is returned from the hook, which aborts the spawn (fail closed).
256#[cfg(unix)]
257fn with_rlimits(mut cmd: Command) -> Command {
258    use std::os::unix::process::CommandExt;
259    unsafe {
260        cmd.pre_exec(|| {
261            set_rlimit(libc::RLIMIT_CPU, limits::CPU_SECONDS)?;
262            set_rlimit(libc::RLIMIT_AS, limits::ADDRESS_SPACE_BYTES)?;
263            set_rlimit(libc::RLIMIT_NOFILE, limits::MAX_FDS)?;
264            set_rlimit(libc::RLIMIT_NPROC, limits::MAX_NPROC)?;
265            Ok(())
266        });
267    }
268    cmd
269}
270
271/// `setrlimit(resource, {cur: v, max: v})` with `v = min(value, hard limit)`,
272/// so a limit an ancestor already lowered is kept rather than refused (raising
273/// a hard limit fails with `EPERM`). Async-signal-safe (two syscalls, no
274/// allocation), as `pre_exec` requires.
275///
276/// `resource` takes libc's raw resource-id type, which is `c_int` on macOS/BSD
277/// but `u32` on Linux (glibc); callers pass the platform's own
278/// `libc::RLIMIT_*` constant, so this stays correct on both without a cast at
279/// each call site.
280#[cfg(unix)]
281fn set_rlimit(resource: RlimitResource, value: u64) -> std::io::Result<()> {
282    let mut limit = libc::rlimit {
283        rlim_cur: 0,
284        rlim_max: 0,
285    };
286    if unsafe { libc::getrlimit(resource, &mut limit) } != 0 {
287        return Err(std::io::Error::last_os_error());
288    }
289    let value = (value as libc::rlim_t).min(limit.rlim_max);
290    let limit = libc::rlimit {
291        rlim_cur: value,
292        rlim_max: value,
293    };
294    if unsafe { libc::setrlimit(resource, &limit) } != 0 {
295        return Err(std::io::Error::last_os_error());
296    }
297    Ok(())
298}
299
300/// Windows: no rlimit equivalent is applied here. Rely on the wall-clock
301/// timeout in `runner.rs` only; a Job Object memory/CPU limiter (via the
302/// `win32job` crate) is future work.
303#[cfg(not(unix))]
304fn with_rlimits(cmd: Command) -> Command {
305    cmd
306}
307
308/// Test hook: the `IMPROV_EXTFN_ALLOW_UNSANDBOXED` fallback (rlimits and a
309/// scrubbed environment, no `bwrap`), whatever this machine has installed.
310#[cfg(test)]
311pub(crate) fn apply_unsandboxed_for_test(cmd: Command) -> Command {
312    apply_with(cmd, SandboxPolicy::Restricted, None, true).expect("opt-out runs")
313}
314
315#[cfg(test)]
316#[cfg_attr(coverage_nightly, coverage(off))]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn policy_is_host_decided() {
322        use std::ffi::OsStr;
323        assert_eq!(policy_from_env(None), SandboxPolicy::Restricted);
324        assert_eq!(
325            policy_from_env(Some(OsStr::new("0"))),
326            SandboxPolicy::Restricted
327        );
328        assert_eq!(
329            policy_from_env(Some(OsStr::new("true"))),
330            SandboxPolicy::Restricted
331        );
332        assert_eq!(
333            policy_from_env(Some(OsStr::new("1"))),
334            SandboxPolicy::Trusted
335        );
336        if std::env::var_os("IMPROV_EXTFN_TRUSTED").is_none() {
337            assert_eq!(policy_for(), SandboxPolicy::Restricted);
338        }
339    }
340
341    #[test]
342    fn trusted_policy_leaves_command_untouched() {
343        let cmd = Command::new("python3");
344        let cmd = apply(cmd, SandboxPolicy::Trusted).expect("trusted");
345        assert_eq!(cmd.get_program(), "python3");
346        assert_eq!(cmd.get_args().count(), 0);
347    }
348
349    #[cfg(target_os = "linux")]
350    #[test]
351    fn bwrap_lookup_matches_path_probe() {
352        // Consistency check against the same probe the runner tests use: if
353        // `which bwrap` finds it, our manual PATH walk must agree.
354        let on_path = std::process::Command::new("which")
355            .arg("bwrap")
356            .stdout(std::process::Stdio::null())
357            .stderr(std::process::Stdio::null())
358            .status()
359            .map(|s| s.success())
360            .unwrap_or(false);
361        assert_eq!(which("bwrap").is_some(), on_path);
362    }
363
364    #[test]
365    fn restricted_without_bwrap_is_refused() {
366        let err = apply_with(
367            Command::new("python3"),
368            SandboxPolicy::Restricted,
369            None,
370            false,
371        )
372        .unwrap_err();
373        match err {
374            ExtFnError::Sandbox(m) => assert_eq!(m, NO_BWRAP),
375            other => panic!("expected Sandbox, got {other:?}"),
376        }
377    }
378
379    #[test]
380    fn restricted_without_bwrap_opt_out_uses_rlimits_only() {
381        let cmd = apply_with(
382            Command::new("python3"),
383            SandboxPolicy::Restricted,
384            None,
385            true,
386        )
387        .expect("opt-out runs");
388        assert_eq!(cmd.get_program(), "python3");
389    }
390
391    #[test]
392    fn restricted_with_bwrap_wraps_the_interpreter() {
393        let mut cmd = Command::new("python3");
394        cmd.arg("-");
395        let cmd = apply_with(
396            cmd,
397            SandboxPolicy::Restricted,
398            Some("/usr/bin/bwrap".into()),
399            false,
400        )
401        .expect("wraps");
402        assert_eq!(cmd.get_program(), "/usr/bin/bwrap");
403        let args: Vec<_> = cmd.get_args().collect();
404        let program = std::path::Path::new(&args[args.len() - 2]);
405        assert_eq!(args[args.len() - 3], "--");
406        assert_eq!(args[args.len() - 1], "-");
407        // Resolved on the host PATH (absolute), or left bare if not found.
408        assert!(program.is_absolute() || program == std::path::Path::new("python3"));
409        assert!(program
410            .file_name()
411            .is_some_and(|n| n.to_string_lossy().starts_with("python3")));
412        for flag in ["--unshare-all", "--clearenv", "--die-with-parent"] {
413            assert!(args.contains(&std::ffi::OsStr::new(flag)), "{flag}");
414        }
415        // The host root is never bound wholesale.
416        assert!(!args.windows(2).any(|w| w[0] == "--ro-bind" && w[1] == "/"));
417        let envs: Vec<_> = cmd.get_envs().collect();
418        assert!(envs.contains(&("PATH".as_ref(), Some(SANDBOX_PATH.as_ref()))));
419    }
420
421    #[cfg(unix)]
422    #[test]
423    fn set_rlimit_reports_failure() {
424        // An unknown resource id is EINVAL: the error must surface (and so
425        // abort the spawn from `pre_exec`), not be swallowed.
426        assert!(set_rlimit(9999, 1).is_err());
427        // Clamped to the hard limit, so asking for more succeeds. CPU, not
428        // NOFILE: macOS refuses an unlimited RLIMIT_NOFILE (setrlimit(2)),
429        // and its default NOFILE hard limit is unlimited.
430        assert!(set_rlimit(libc::RLIMIT_CPU, u64::MAX).is_ok());
431    }
432
433    /// The program is run by its canonical path, so a symlinked interpreter
434    /// (a Nix profile, a shim) still resolves inside the sandbox, where only
435    /// the system directories (and `/nix/store`, when present) are bound.
436    #[cfg(unix)]
437    #[test]
438    fn wrapped_program_is_the_canonical_path() {
439        let dir = std::env::temp_dir().join(format!("improv-canon-{}", std::process::id()));
440        std::fs::create_dir_all(&dir).unwrap();
441        let real = dir.join("real-python3");
442        std::fs::write(&real, "").unwrap();
443        let link = dir.join("python3");
444        let _ = std::fs::remove_file(&link);
445        std::os::unix::fs::symlink(&real, &link).unwrap();
446        let wrapped = wrap_bwrap(Path::new("/usr/bin/bwrap"), &Command::new(&link));
447        let last = wrapped.get_args().last().expect("program");
448        assert_eq!(Path::new(last), std::fs::canonicalize(&real).unwrap());
449        let _ = std::fs::remove_dir_all(&dir);
450    }
451
452    /// Without `LANG` in the host env, neither the scrubbed env nor the bwrap
453    /// command line carries one.
454    #[test]
455    fn lang_is_forwarded_only_when_set() {
456        let saved = std::env::var_os("LANG");
457        std::env::remove_var("LANG");
458        let scrubbed = scrub_env(Command::new("python3"));
459        let wrapped = wrap_bwrap(Path::new("/usr/bin/bwrap"), &Command::new("python3"));
460        std::env::set_var("LANG", "C.UTF-8");
461        let scrubbed_lang = scrub_env(Command::new("python3"));
462        let wrapped_lang = wrap_bwrap(Path::new("/usr/bin/bwrap"), &Command::new("python3"));
463        match saved {
464            Some(v) => std::env::set_var("LANG", v),
465            None => std::env::remove_var("LANG"),
466        }
467        let has_lang = |c: &Command| c.get_envs().any(|(k, _)| k == "LANG");
468        let setenv = |c: &Command| c.get_args().any(|a| a == "LANG");
469        assert!(!has_lang(&scrubbed) && !setenv(&wrapped));
470        assert!(has_lang(&scrubbed_lang) && setenv(&wrapped_lang));
471        let args: Vec<_> = wrapped.get_args().collect();
472        let last = std::path::Path::new(args.last().copied().expect("program"));
473        assert!(last
474            .file_name()
475            .is_some_and(|n| n.to_string_lossy().starts_with("python3")));
476    }
477}