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}