Skip to main content

noether_engine/executor/
isolation.rs

1//! Stage execution isolation.
2//!
3//! Wraps subprocess execution in a sandbox that restricts what the
4//! stage can read, write, and call. Closes the gap documented in
5//! `SECURITY.md`: a user-authored Python stage has host-user
6//! privileges by default; with isolation it runs in a bounded
7//! filesystem + network namespace.
8//!
9//! Phase 1 (v0.7) backends:
10//!
11//! - [`IsolationBackend::None`] — legacy pass-through. Emits a
12//!   warning unless the user opts in with
13//!   `--unsafe-no-isolation` / `NOETHER_ISOLATION=none`.
14//! - [`IsolationBackend::Bwrap`] — bubblewrap wrapper. Linux-only.
15//!   Requires the `bwrap` binary in `PATH`.
16//!
17//! Phase 2 (v0.8) will add `IsolationBackend::Native` — direct
18//! `unshare(2)` + Landlock + seccomp syscalls, no external binary.
19//! See `docs/roadmap/2026-04-18-stage-isolation.md`.
20//!
21//! ## Policy derivation
22//!
23//! An [`IsolationPolicy`] is derived from a stage's declared
24//! `EffectSet`. Phase 1 surfaces exactly one axis from the effect
25//! vocabulary — `Effect::Network` toggles whether the sandbox
26//! inherits the host's network namespace. Every other effect
27//! variant (`Pure`, `Fallible`, `Llm`, `NonDeterministic`, `Process`,
28//! `Cost`, `Unknown`) produces the same baseline policy: RO
29//! `/nix/store` bind, a sandbox-private `/work` tmpfs,
30//! `--cap-drop ALL`, UID/GID mapped to nobody, `--clearenv` with a
31//! short allowlist.
32//!
33//! ### TLS trust store — dual path
34//!
35//! When `network=true`, the sandbox binds `/etc/ssl/certs`
36//! (via `--ro-bind-try`) for non-Nix-aware clients that expect the
37//! system trust store (curl, openssl). Nix-built code uses
38//! `NIX_SSL_CERT_FILE` / `SSL_CERT_FILE` (both in the env
39//! allowlist) pointing into `/nix/store`, which is always bound.
40//! So TLS works whether the stage resolves certs through the
41//! filesystem path or the env-pointer path; NixOS hosts without
42//! `/etc/ssl/certs` fall through to the env path automatically.
43//!
44//! ### Filesystem effects — not yet expressible
45//!
46//! The v0.6 `Effect` enum has no `FsRead(path)` / `FsWrite(path)`
47//! variants, so there is no way for a stage to declare "I need to
48//! read `/etc/ssl` but nothing else." The sandbox compensates by
49//! allowing *nothing* outside `/nix/store`, the executor's cache
50//! dir, and the nix binary. That is the strictest sane default —
51//! but it means stages that legitimately need a specific host path
52//! cannot run under isolation today. Planned for v0.8: extend
53//! `Effect` with `FsRead` / `FsWrite` path variants, then expand
54//! `from_effects` to translate them into bind mounts. Tracked in
55//! `docs/roadmap/2026-04-18-stage-isolation.md`.
56
57use noether_core::effects::{Effect, EffectSet};
58use std::path::{Path, PathBuf};
59use std::process::Command;
60use std::sync::atomic::{AtomicBool, Ordering};
61
62/// Which isolation backend to use for a stage execution.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum IsolationBackend {
65    /// No isolation — legacy behaviour. A malicious stage can read
66    /// host files, call out to the network, write to the user's
67    /// home directory. Noether emits a warning the first time this
68    /// backend is used unless `--unsafe-no-isolation` is set.
69    None,
70    /// Wrap the stage subprocess in `bwrap`. Requires the
71    /// bubblewrap binary in `PATH`. Linux-only.
72    Bwrap { bwrap_path: PathBuf },
73}
74
75impl IsolationBackend {
76    /// Resolve `"auto"`: pick the best backend available on this
77    /// host. On Linux with `bwrap` on `PATH`, that's
78    /// [`IsolationBackend::Bwrap`]. Elsewhere, falls back to
79    /// [`IsolationBackend::None`] with the returned warning string
80    /// so the caller can surface it.
81    pub fn auto() -> (Self, Option<String>) {
82        if let Some(path) = find_bwrap() {
83            return (IsolationBackend::Bwrap { bwrap_path: path }, None);
84        }
85        (
86            IsolationBackend::None,
87            Some(
88                "isolation backend 'auto' could not find bubblewrap \
89                 (bwrap) on PATH; stage execution runs with full host-user \
90                 privileges. Install bubblewrap (apt/brew/nix) to enable \
91                 sandboxing, or pass --unsafe-no-isolation to silence \
92                 this warning."
93                    .into(),
94            ),
95        )
96    }
97
98    /// Parse the `--isolate` / `NOETHER_ISOLATION` argument.
99    pub fn from_flag(flag: &str) -> Result<(Self, Option<String>), IsolationError> {
100        match flag {
101            "auto" => Ok(Self::auto()),
102            "bwrap" => match find_bwrap() {
103                Some(path) => Ok((IsolationBackend::Bwrap { bwrap_path: path }, None)),
104                None => Err(IsolationError::BackendUnavailable {
105                    backend: "bwrap".into(),
106                    reason: "binary not found in PATH".into(),
107                }),
108            },
109            "none" => Ok((IsolationBackend::None, None)),
110            other => Err(IsolationError::UnknownBackend { name: other.into() }),
111        }
112    }
113
114    pub fn is_effective(&self) -> bool {
115        !matches!(self, IsolationBackend::None)
116    }
117}
118
119/// Error from the isolation layer itself — policy misconfiguration
120/// or backend unavailable. Stage-body errors come back as the usual
121/// `ExecutionError` on the inner command.
122#[derive(Debug, Clone, PartialEq, thiserror::Error)]
123pub enum IsolationError {
124    #[error("isolation backend '{name}' is not recognised; expected one of: auto, bwrap, none")]
125    UnknownBackend { name: String },
126
127    #[error("isolation backend '{backend}' is unavailable: {reason}")]
128    BackendUnavailable { backend: String, reason: String },
129}
130
131/// What the sandbox does and doesn't let a stage reach.
132///
133/// Derived from a stage's `EffectSet` via
134/// [`IsolationPolicy::from_effects`]. Callers rarely construct this
135/// manually; it's shaped so the stage executor can translate it into
136/// backend-specific flags (bwrap args in Phase 1, unshare+landlock+seccomp
137/// in Phase 2).
138#[derive(Debug, Clone)]
139pub struct IsolationPolicy {
140    /// Read-only bind mounts: `(host_path, sandbox_path)`. Always
141    /// includes `/nix/store` so Nix-pinned runtimes resolve inside
142    /// the sandbox.
143    pub ro_binds: Vec<(PathBuf, PathBuf)>,
144    /// Scratch directory strategy for `/work` inside the sandbox.
145    ///
146    /// - `None` (recommended, and the default from [`Self::from_effects`])
147    ///   → `bwrap` creates `/work` as a sandbox-private tmpfs via
148    ///   `--dir /work`. No host-side path exists; cleanup happens
149    ///   automatically when the sandbox exits; a malicious host user
150    ///   can't race to write predicatable filenames into the work
151    ///   dir before the stage runs.
152    /// - `Some(host)` → `--bind <host> /work`. Host dir must exist
153    ///   and be writable by the sandbox's effective UID (65534 by
154    ///   default). Only for callers that need to inspect the work
155    ///   dir after execution — e.g., an integration test.
156    pub work_host: Option<PathBuf>,
157    /// Inherit the host's network namespace (`true`) or unshare into
158    /// a fresh empty one (`false`). Only `true` when the stage has
159    /// `Effect::Network`.
160    pub network: bool,
161    /// Environment variables to pass through to the sandboxed
162    /// process. Everything else in the parent environment is
163    /// cleared.
164    pub env_allowlist: Vec<String>,
165}
166
167impl IsolationPolicy {
168    /// Build the policy for a stage with the given effects.
169    ///
170    /// Defaults to a sandbox-private `/work` (tmpfs, no host-side
171    /// state). Callers that need a host-visible work dir can swap in
172    /// [`Self::with_work_host`].
173    pub fn from_effects(effects: &EffectSet) -> Self {
174        let has_network = effects.iter().any(|e| matches!(e, Effect::Network));
175        Self {
176            ro_binds: vec![(PathBuf::from("/nix/store"), PathBuf::from("/nix/store"))],
177            work_host: None,
178            network: has_network,
179            env_allowlist: vec![
180                "PATH".into(),
181                "HOME".into(),
182                "USER".into(),
183                "LANG".into(),
184                "LC_ALL".into(),
185                "LC_CTYPE".into(),
186                "NIX_PATH".into(),
187                "NIX_SSL_CERT_FILE".into(),
188                "SSL_CERT_FILE".into(),
189                "NOETHER_LOG_LEVEL".into(),
190                "RUST_LOG".into(),
191            ],
192        }
193    }
194
195    /// Override the sandbox's `/work` to bind a caller-provided host
196    /// directory. The directory must already exist and be writable by
197    /// the sandbox effective UID (65534). Consumers mostly leave the
198    /// default (tmpfs).
199    pub fn with_work_host(mut self, host: PathBuf) -> Self {
200        self.work_host = Some(host);
201        self
202    }
203}
204
205/// Conventional "nobody" UID/GID on Linux. bwrap maps the invoking
206/// user to this identity inside the sandbox so the stage cannot
207/// observe the real UID of the caller.
208pub(crate) const NOBODY_UID: u32 = 65534;
209pub(crate) const NOBODY_GID: u32 = 65534;
210
211/// Build a `bwrap` invocation that runs `cmd` inside a sandbox.
212///
213/// Returns a `Command` ready to spawn — the caller keeps ownership
214/// of stdin/stdout/stderr piping and waits on the child. The
215/// `work_host` path must exist; `bwrap` will fail otherwise.
216///
217/// Flags used (see bubblewrap(1)):
218///
219/// - `--unshare-all` — fresh user, pid, uts, ipc, mount, cgroup
220///   namespaces. Network namespace is unshared too, unless the
221///   policy re-shares via `--share-net` (see below).
222/// - `--uid 65534 --gid 65534` — map the invoking user to
223///   `nobody/nogroup` inside the sandbox. Without this, the stage
224///   would observe the host user's real UID (informational leak,
225///   and potentially exploitable when combined with filesystem
226///   bind-mount misconfiguration).
227/// - `--die-with-parent` — if the parent dies, so does the sandbox.
228/// - `--proc /proc`, `--dev /dev` — standard Linux mounts.
229/// - `--ro-bind <host> <sandbox>` — read-only mounts from the
230///   policy's `ro_binds`. Always includes `/nix/store`.
231/// - `--bind <work_host> /work` — writable scratch.
232/// - `--chdir /work` — subprocess starts in the scratch dir.
233/// - `--clearenv` — wipe the environment; the executor re-adds the
234///   allowlisted variables via `.env(...)`.
235/// - `--share-net` — only when `policy.network` is true.
236/// - `--cap-drop ALL` — drop every capability inside the sandbox.
237pub fn build_bwrap_command(
238    bwrap: &Path,
239    policy: &IsolationPolicy,
240    inner_cmd: &[String],
241) -> Command {
242    let mut c = Command::new(bwrap);
243    c.arg("--unshare-all")
244        .arg("--die-with-parent")
245        .arg("--new-session")
246        .arg("--uid")
247        .arg(NOBODY_UID.to_string())
248        .arg("--gid")
249        .arg(NOBODY_GID.to_string())
250        .arg("--proc")
251        .arg("/proc")
252        .arg("--dev")
253        .arg("/dev")
254        .arg("--tmpfs")
255        .arg("/tmp")
256        .arg("--clearenv")
257        .arg("--cap-drop")
258        .arg("ALL");
259
260    if policy.network {
261        c.arg("--share-net");
262        // `--share-net` re-enters the host network namespace but the
263        // sandbox rootfs is otherwise empty. glibc NSS resolves DNS
264        // through `/etc/resolv.conf`, `/etc/nsswitch.conf`, and
265        // `/etc/hosts`; without those, even a correctly networked
266        // sandbox can't resolve hostnames. `--ro-bind-try` is a
267        // no-op when the source is absent (e.g. NixOS systems that
268        // route DNS differently), so it's safe to emit regardless.
269        //
270        // `/etc/ssl/certs` covers non-Nix-aware clients (curl,
271        // openssl, etc.) that expect the system trust store.
272        // Nix-built code uses `NIX_SSL_CERT_FILE` / `SSL_CERT_FILE`
273        // (already in the env allowlist) to point into `/nix/store`,
274        // which is bound separately.
275        for etc_path in [
276            "/etc/resolv.conf",
277            "/etc/hosts",
278            "/etc/nsswitch.conf",
279            "/etc/ssl/certs",
280        ] {
281            c.arg("--ro-bind-try").arg(etc_path).arg(etc_path);
282        }
283    }
284
285    for (host, sandbox) in &policy.ro_binds {
286        c.arg("--ro-bind").arg(host).arg(sandbox);
287    }
288
289    match &policy.work_host {
290        Some(host) => {
291            c.arg("--bind").arg(host).arg("/work");
292        }
293        None => {
294            // Sandbox-private tmpfs at /work. No host-side path,
295            // so nothing to clean up and nothing for a host-side
296            // attacker to race into before the sandbox starts.
297            c.arg("--dir").arg("/work");
298        }
299    }
300    c.arg("--chdir").arg("/work");
301
302    // Env: `--clearenv` wipes the inner process's inherited env,
303    // then `--setenv` repopulates it. Setting `cmd.env(...)` on the
304    // outer `Command` would only affect `bwrap` itself, not the
305    // inner command — that was the trap the previous design fell
306    // into (HOME was set on bwrap but stripped before the stage
307    // ran, so `nix` crashed looking for a home directory).
308    //
309    // HOME / USER are always set to sandbox-consistent values
310    // (/work + "nobody" matching the UID mapping). Other allowlist
311    // entries inherit their value from the invoking process if set
312    // there.
313    c.arg("--setenv").arg("HOME").arg("/work");
314    c.arg("--setenv").arg("USER").arg("nobody");
315    for var in &policy.env_allowlist {
316        if var == "HOME" || var == "USER" {
317            continue;
318        }
319        if let Ok(v) = std::env::var(var) {
320            c.arg("--setenv").arg(var).arg(v);
321        }
322    }
323
324    c.arg("--").args(inner_cmd);
325    c
326}
327
328/// Locate the `bwrap` binary.
329///
330/// Checks a fixed list of trusted system paths first, because they're
331/// owned by root on every mainstream Linux distro and therefore can't
332/// be planted by a non-privileged attacker. Only if none of those
333/// exist does the search fall back to walking `$PATH` — at which
334/// point a `tracing::warn!` fires (once per process) so operators can
335/// notice that isolation is trusting an attacker-plantable lookup.
336///
337/// Returns `None` if `bwrap` is not installed anywhere we know to look.
338pub fn find_bwrap() -> Option<PathBuf> {
339    for trusted in TRUSTED_BWRAP_PATHS {
340        let candidate = PathBuf::from(trusted);
341        if candidate.is_file() {
342            return Some(candidate);
343        }
344    }
345
346    // Fallback: $PATH walk. Operators with a properly-provisioned
347    // host should never hit this branch; if they do, either `bwrap`
348    // was installed somewhere non-standard or the host's `$PATH` is
349    // pointing at attacker-writable directories (user shell rc files,
350    // container bind-mount mishaps, etc.).
351    let path_env = std::env::var_os("PATH")?;
352    for dir in std::env::split_paths(&path_env) {
353        let candidate = dir.join("bwrap");
354        if candidate.is_file() {
355            if !PATH_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
356                tracing::warn!(
357                    resolved = %candidate.display(),
358                    "bwrap resolved via $PATH — none of the trusted \
359                     system paths contained it. If this host's PATH \
360                     includes a user-writable directory, isolation can \
361                     be trivially bypassed. Install bwrap to /usr/bin \
362                     (distro package) or your system Nix profile."
363                );
364            }
365            return Some(candidate);
366        }
367    }
368    None
369}
370
371static PATH_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
372
373/// Root-owned locations where `bwrap` lives on a correctly-provisioned
374/// Linux host. Order matters: NixOS system profile first (nix hosts
375/// almost always have this), then the Determinate / single-user nix
376/// profile, then distro-packaged `/usr/bin`, then manual installs.
377///
378/// A non-root attacker can't write to any of these on a standard
379/// Linux system, so resolving through them short-circuits the
380/// `$PATH` planting vector. Linux-only: bwrap doesn't run on macOS
381/// or Windows, and typical macOS install paths (e.g. `/opt/homebrew`)
382/// are owned by the installing admin user, not root, so including
383/// them here would re-introduce the planting vector we're closing.
384pub(crate) const TRUSTED_BWRAP_PATHS: &[&str] = &[
385    "/run/current-system/sw/bin/bwrap",
386    "/nix/var/nix/profiles/default/bin/bwrap",
387    "/usr/bin/bwrap",
388    "/usr/local/bin/bwrap",
389];
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use noether_core::effects::{Effect, EffectSet};
395
396    #[test]
397    fn from_flag_parses_known_values() {
398        assert!(matches!(
399            IsolationBackend::from_flag("none").unwrap().0,
400            IsolationBackend::None
401        ));
402        assert!(IsolationBackend::from_flag("unknown").is_err());
403    }
404
405    #[test]
406    fn policy_without_network_effect_isolates_network() {
407        let effects = EffectSet::pure();
408        let policy = IsolationPolicy::from_effects(&effects);
409        assert!(!policy.network);
410    }
411
412    #[test]
413    fn policy_with_network_effect_shares_network() {
414        let effects = EffectSet::new([Effect::Pure, Effect::Network]);
415        let policy = IsolationPolicy::from_effects(&effects);
416        assert!(policy.network);
417    }
418
419    #[test]
420    fn policy_defaults_to_sandbox_private_work() {
421        // New default after the v0.7 hardening: no host-side workdir.
422        let policy = IsolationPolicy::from_effects(&EffectSet::pure());
423        assert!(
424            policy.work_host.is_none(),
425            "from_effects must default to sandbox-private /work; \
426             callers asking for host-visible scratch must opt in via \
427             .with_work_host(...)"
428        );
429    }
430
431    #[test]
432    fn policy_always_binds_nix_store() {
433        let policy = IsolationPolicy::from_effects(&EffectSet::pure());
434        let (host, sandbox) = policy
435            .ro_binds
436            .iter()
437            .find(|(_, s)| s == Path::new("/nix/store"))
438            .expect("nix store bind is missing");
439        assert_eq!(host, Path::new("/nix/store"));
440        assert_eq!(sandbox, Path::new("/nix/store"));
441    }
442
443    #[test]
444    fn bwrap_command_includes_core_flags() {
445        let policy = IsolationPolicy::from_effects(&EffectSet::pure());
446        let cmd = build_bwrap_command(
447            Path::new("/usr/bin/bwrap"),
448            &policy,
449            &["python3".into(), "script.py".into()],
450        );
451        let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
452
453        assert!(argv.contains(&"--unshare-all".to_string()));
454        assert!(argv.contains(&"--clearenv".to_string()));
455        assert!(argv.contains(&"--cap-drop".to_string()));
456        assert!(argv.contains(&"ALL".to_string()));
457        assert!(argv.contains(&"--die-with-parent".to_string()));
458        // No --share-net when no Network effect.
459        assert!(!argv.contains(&"--share-net".to_string()));
460        // Default workdir is sandbox-private tmpfs, not a host bind.
461        assert!(argv.contains(&"--dir".to_string()));
462        assert!(argv.contains(&"/work".to_string()));
463        // Inner command appended after --.
464        let dash_dash_idx = argv
465            .iter()
466            .position(|a| a == "--")
467            .expect("missing -- separator");
468        assert_eq!(argv[dash_dash_idx + 1], "python3");
469    }
470
471    #[test]
472    fn bwrap_command_uses_host_bind_when_work_host_set() {
473        // Integration tests and debugging tools can still opt into a
474        // host-visible work dir via `with_work_host`.
475        let policy = IsolationPolicy::from_effects(&EffectSet::pure())
476            .with_work_host(PathBuf::from("/tmp/inspect-me"));
477        let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
478        let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
479        let bind_pos = argv
480            .iter()
481            .position(|a| a == "--bind")
482            .expect("--bind missing");
483        assert_eq!(argv[bind_pos + 1], "/tmp/inspect-me");
484        assert_eq!(argv[bind_pos + 2], "/work");
485    }
486
487    #[test]
488    fn bwrap_command_adds_share_net_for_network_effect() {
489        let policy =
490            IsolationPolicy::from_effects(&EffectSet::new([Effect::Pure, Effect::Network]));
491        let cmd = build_bwrap_command(
492            Path::new("/usr/bin/bwrap"),
493            &policy,
494            &["curl".into(), "https://example.com".into()],
495        );
496        let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
497        assert!(argv.contains(&"--share-net".to_string()));
498    }
499
500    #[test]
501    fn bwrap_command_maps_to_nobody_uid_and_gid() {
502        // Regression guard: the sandbox must not surface the invoking
503        // user's real UID. Without `--uid 65534 --gid 65534` a stage
504        // can call `os.getuid()` / `id` and observe the host user —
505        // that's both an info leak and a stepping stone when combined
506        // with any bind-mount misconfiguration.
507        let policy = IsolationPolicy::from_effects(&EffectSet::pure());
508        let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
509        let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
510
511        let uid_pos = argv
512            .iter()
513            .position(|a| a == "--uid")
514            .expect("--uid missing");
515        assert_eq!(argv[uid_pos + 1], "65534");
516        let gid_pos = argv
517            .iter()
518            .position(|a| a == "--gid")
519            .expect("--gid missing");
520        assert_eq!(argv[gid_pos + 1], "65534");
521    }
522
523    #[test]
524    fn trusted_bwrap_paths_are_root_owned_on_linux() {
525        // Contract check: every entry in TRUSTED_BWRAP_PATHS must
526        // point at a directory that is conventionally root-owned on
527        // mainstream Linux hosts. If someone adds a user-writable
528        // path here (e.g. `~/.local/bin`, or `/opt/homebrew` which
529        // is admin-user-owned on macOS) they re-introduce the
530        // planting vector `find_bwrap` was written to close. bwrap
531        // is Linux-only, so macOS-adjacent paths don't belong in
532        // the list. Keep it literal and reviewable — no
533        // interpolation, no platform branching at runtime.
534        for p in TRUSTED_BWRAP_PATHS {
535            assert!(
536                p.starts_with("/run/") || p.starts_with("/nix/var/") || p.starts_with("/usr/"),
537                "TRUSTED_BWRAP_PATHS entry '{p}' is not conventionally \
538                 root-owned on Linux; only /run /nix/var /usr prefixes \
539                 are permitted"
540            );
541        }
542    }
543
544    #[test]
545    fn effectiveness_predicate_matches_variant() {
546        assert!(!IsolationBackend::None.is_effective());
547        assert!(IsolationBackend::Bwrap {
548            bwrap_path: PathBuf::from("/usr/bin/bwrap"),
549        }
550        .is_effective());
551    }
552}