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}