noether_isolation/lib.rs
1//! Stage execution isolation — the sandbox primitive extracted from
2//! [`noether_engine::executor::isolation`] for consumers that want
3//! isolation without pulling in the composition engine.
4//!
5//! The `noether-engine` crate re-exports this module verbatim, so
6//! existing callers see no API change. Downstream consumers
7//! ([`agentspec`](https://github.com/alpibrusl/agentspec), the
8//! standalone `noether-sandbox` binary) depend on this crate
9//! directly.
10//!
11//! Wraps subprocess execution in a sandbox that restricts what the
12//! stage can read, write, and call. Closes the gap documented in
13//! `SECURITY.md`: a user-authored Python stage has host-user
14//! privileges by default; with isolation it runs in a bounded
15//! filesystem + network namespace.
16//!
17//! Phase 1 (v0.7) backends:
18//!
19//! - [`IsolationBackend::None`] — legacy pass-through. Emits a
20//! warning unless the user opts in with
21//! `--unsafe-no-isolation` / `NOETHER_ISOLATION=none`.
22//! - [`IsolationBackend::Bwrap`] — bubblewrap wrapper. Linux-only.
23//! Requires the `bwrap` binary in `PATH`.
24//!
25//! Phase 2 (v0.8) will add `IsolationBackend::Native` — direct
26//! `unshare(2)` + Landlock + seccomp syscalls, no external binary.
27//! See `docs/roadmap/2026-04-18-stage-isolation.md`.
28//!
29//! ## Policy derivation
30//!
31//! An [`IsolationPolicy`] is derived from a stage's declared
32//! `EffectSet`. Phase 1 surfaces exactly one axis from the effect
33//! vocabulary — `Effect::Network` toggles whether the sandbox
34//! inherits the host's network namespace. Every other effect
35//! variant (`Pure`, `Fallible`, `Llm`, `NonDeterministic`, `Process`,
36//! `Cost`, `Unknown`) produces the same baseline policy: RO
37//! `/nix/store` bind, a sandbox-private `/work` tmpfs,
38//! `--cap-drop ALL`, UID/GID mapped to nobody, `--clearenv` with a
39//! short allowlist.
40//!
41//! ### TLS trust store — dual path
42//!
43//! When `network=true`, the sandbox binds `/etc/ssl/certs`
44//! (via `--ro-bind-try`) for non-Nix-aware clients that expect the
45//! system trust store (curl, openssl). Nix-built code uses
46//! `NIX_SSL_CERT_FILE` / `SSL_CERT_FILE` (both in the env
47//! allowlist) pointing into `/nix/store`, which is always bound.
48//! So TLS works whether the stage resolves certs through the
49//! filesystem path or the env-pointer path; NixOS hosts without
50//! `/etc/ssl/certs` fall through to the env path automatically.
51//!
52//! ### Filesystem effects — not yet expressible
53//!
54//! The v0.6 `Effect` enum has no `FsRead(path)` / `FsWrite(path)`
55//! variants, so there is no way for a stage to declare "I need to
56//! read `/etc/ssl` but nothing else." The sandbox compensates by
57//! allowing *nothing* outside `/nix/store`, the executor's cache
58//! dir, and the nix binary. That is the strictest sane default —
59//! but it means stages that legitimately need a specific host path
60//! cannot run under isolation today. Planned for v0.8: extend
61//! `Effect` with `FsRead` / `FsWrite` path variants, then expand
62//! `from_effects` to translate them into bind mounts. Tracked in
63//! `docs/roadmap/2026-04-18-stage-isolation.md`.
64
65use noether_core::effects::{Effect, EffectSet};
66use serde::{Deserialize, Serialize};
67use std::path::{Path, PathBuf};
68use std::process::Command;
69use std::sync::atomic::{AtomicBool, Ordering};
70
71/// Which isolation backend to use for a stage execution.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(tag = "kind", rename_all = "snake_case")]
74pub enum IsolationBackend {
75 /// No isolation — legacy behaviour. A malicious stage can read
76 /// host files, call out to the network, write to the user's
77 /// home directory. Noether emits a warning the first time this
78 /// backend is used unless `--unsafe-no-isolation` is set.
79 None,
80 /// Wrap the stage subprocess in `bwrap`. Requires the
81 /// bubblewrap binary in `PATH`. Linux-only.
82 Bwrap { bwrap_path: PathBuf },
83}
84
85impl IsolationBackend {
86 /// Resolve `"auto"`: pick the best backend available on this
87 /// host. On Linux with `bwrap` on `PATH`, that's
88 /// [`IsolationBackend::Bwrap`]. Elsewhere, falls back to
89 /// [`IsolationBackend::None`] with the returned warning string
90 /// so the caller can surface it.
91 pub fn auto() -> (Self, Option<String>) {
92 if let Some(path) = find_bwrap() {
93 return (IsolationBackend::Bwrap { bwrap_path: path }, None);
94 }
95 (
96 IsolationBackend::None,
97 Some(
98 "isolation backend 'auto' could not find bubblewrap \
99 (bwrap) on PATH; stage execution runs with full host-user \
100 privileges. Install bubblewrap (apt/brew/nix) to enable \
101 sandboxing, or pass --unsafe-no-isolation to silence \
102 this warning."
103 .into(),
104 ),
105 )
106 }
107
108 /// Parse the `--isolate` / `NOETHER_ISOLATION` argument.
109 pub fn from_flag(flag: &str) -> Result<(Self, Option<String>), IsolationError> {
110 match flag {
111 "auto" => Ok(Self::auto()),
112 "bwrap" => match find_bwrap() {
113 Some(path) => Ok((IsolationBackend::Bwrap { bwrap_path: path }, None)),
114 None => Err(IsolationError::BackendUnavailable {
115 backend: "bwrap".into(),
116 reason: "binary not found in PATH".into(),
117 }),
118 },
119 "none" => Ok((IsolationBackend::None, None)),
120 other => Err(IsolationError::UnknownBackend { name: other.into() }),
121 }
122 }
123
124 pub fn is_effective(&self) -> bool {
125 !matches!(self, IsolationBackend::None)
126 }
127}
128
129/// Error from the isolation layer itself — policy misconfiguration
130/// or backend unavailable. Stage-body errors come back as the usual
131/// execution error on the inner command.
132#[derive(Debug, Clone, PartialEq, thiserror::Error, Serialize, Deserialize)]
133#[serde(tag = "kind", rename_all = "snake_case")]
134pub enum IsolationError {
135 #[error("isolation backend '{name}' is not recognised; expected one of: auto, bwrap, none")]
136 UnknownBackend { name: String },
137
138 #[error("isolation backend '{backend}' is unavailable: {reason}")]
139 BackendUnavailable { backend: String, reason: String },
140}
141
142/// A single read-only bind mount. Named-struct rather than a tuple
143/// so the JSON wire format stays readable for non-Rust consumers:
144/// `{"host": "/nix/store", "sandbox": "/nix/store"}` instead of the
145/// earlier `["/nix/store", "/nix/store"]`. The latter was terser but
146/// gave external language bindings no schema hint about which path
147/// was which.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct RoBind {
150 /// Host-side path. Must exist; `bwrap` will fail otherwise.
151 pub host: PathBuf,
152 /// Path inside the sandbox where the host dir/file appears.
153 pub sandbox: PathBuf,
154}
155
156impl RoBind {
157 pub fn new(host: impl Into<PathBuf>, sandbox: impl Into<PathBuf>) -> Self {
158 Self {
159 host: host.into(),
160 sandbox: sandbox.into(),
161 }
162 }
163}
164
165impl From<(PathBuf, PathBuf)> for RoBind {
166 fn from((host, sandbox): (PathBuf, PathBuf)) -> Self {
167 Self { host, sandbox }
168 }
169}
170
171/// A single read-write bind mount. The exact counterpart of [`RoBind`]
172/// for the `rw_binds` field — same wire shape, same ergonomics, same
173/// `From<(PathBuf, PathBuf)>` convenience.
174///
175/// # Trust semantics
176///
177/// `RwBind` is a deliberate trust widening. The crate's default
178/// posture — `work_host: None` with a sandbox-private tmpfs `/work`,
179/// and `ro_binds` containing only `/nix/store` — is what
180/// [`IsolationPolicy::from_effects`] produces, and it's the shape
181/// that keeps the sandbox meaningful.
182///
183/// The moment a caller adds an `RwBind` to the policy, the stage
184/// inside the sandbox can write to the corresponding host path. The
185/// crate does not — cannot — validate whether that's a sensible
186/// thing to share. Binding `/home/user` RW gives the stage the
187/// caller's entire home directory; binding a project workdir RW is
188/// the whole point of an agent-coding tool. Both use exactly the
189/// same mechanism. The *policy* decision lives with the caller.
190///
191/// No `from_effects` variant produces an `RwBind`. The `EffectSet`
192/// vocabulary has no `FsWrite(path)` variant (noted in the
193/// module-level rustdoc), so effects alone can't drive the shape.
194/// Consumers that want RW binds construct the policy directly and
195/// take responsibility for the trust decision.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct RwBind {
198 /// Host-side path. Must exist; `bwrap` will fail otherwise.
199 pub host: PathBuf,
200 /// Path inside the sandbox where the host dir/file appears.
201 pub sandbox: PathBuf,
202}
203
204impl RwBind {
205 pub fn new(host: impl Into<PathBuf>, sandbox: impl Into<PathBuf>) -> Self {
206 Self {
207 host: host.into(),
208 sandbox: sandbox.into(),
209 }
210 }
211}
212
213impl From<(PathBuf, PathBuf)> for RwBind {
214 fn from((host, sandbox): (PathBuf, PathBuf)) -> Self {
215 Self { host, sandbox }
216 }
217}
218
219/// What the sandbox does and doesn't let a stage reach.
220///
221/// Derived from a stage's `EffectSet` via
222/// [`IsolationPolicy::from_effects`]. Callers rarely construct this
223/// manually; it's shaped so the stage executor can translate it into
224/// backend-specific flags (bwrap args in Phase 1, unshare+landlock+seccomp
225/// in Phase 2). Serde-enabled so downstream consumers (e.g. the
226/// `noether-sandbox` binary) can exchange policies over IPC.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct IsolationPolicy {
229 /// Read-only bind mounts. Always includes `/nix/store` so
230 /// Nix-pinned runtimes resolve inside the sandbox.
231 pub ro_binds: Vec<RoBind>,
232 /// Read-write bind mounts. Empty by default; never populated by
233 /// [`Self::from_effects`] (effects alone don't carry enough
234 /// information to justify a trust widening — see [`RwBind`]).
235 ///
236 /// # Interaction with `ro_binds` and `work_host` (mount order)
237 ///
238 /// `bwrap` processes bind flags in argv order; a later flag whose
239 /// sandbox path sits under an earlier flag's sandbox path shadows
240 /// the earlier one for that subtree. [`build_bwrap_command`]
241 /// emits binds in this fixed order:
242 ///
243 /// 1. `rw_binds` (this field) — `--bind <host> <sandbox>` per entry.
244 /// 2. `ro_binds` — `--ro-bind <host> <sandbox>` per entry.
245 /// 3. `work_host` — `--bind <host> /work` (if `Some`), else
246 /// `--dir /work` (sandbox-private tmpfs).
247 ///
248 /// Why RW-then-RO: the agentspec-ish pattern is *"this agent
249 /// operates on my `~/projects/foo` directory RW, but its `.ssh`
250 /// subdirectory stays RO."* With RW emitted first, the narrower
251 /// RO shadows the broader RW — which is the default-ergonomic
252 /// outcome. Reversing the order would make the RW bind silently
253 /// override an attempt to protect a subpath.
254 ///
255 /// `work_host` renders *after* both vectors, so a `work_host`
256 /// that happens to sit under an earlier bind wins at `/work`.
257 /// This matches the pre-existing behaviour on `ro_binds` alone
258 /// and is the mapping the executor expects.
259 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 pub rw_binds: Vec<RwBind>,
261 /// Scratch directory strategy for `/work` inside the sandbox.
262 ///
263 /// - `None` (recommended, and the default from [`Self::from_effects`])
264 /// → `bwrap` creates `/work` as a sandbox-private tmpfs via
265 /// `--dir /work`. No host-side path exists; cleanup happens
266 /// automatically when the sandbox exits; a malicious host user
267 /// can't race to write predicatable filenames into the work
268 /// dir before the stage runs.
269 /// - `Some(host)` → `--bind <host> /work`. Host dir must exist
270 /// and be writable by the sandbox's effective UID (65534 by
271 /// default). Only for callers that need to inspect the work
272 /// dir after execution — e.g., an integration test.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub work_host: Option<PathBuf>,
275 /// Inherit the host's network namespace (`true`) or unshare into
276 /// a fresh empty one (`false`). Only `true` when the stage has
277 /// `Effect::Network`.
278 pub network: bool,
279 /// Environment variables to pass through to the sandboxed
280 /// process. Everything else in the parent environment is
281 /// cleared.
282 pub env_allowlist: Vec<String>,
283}
284
285impl IsolationPolicy {
286 /// Build the policy for a stage with the given effects.
287 ///
288 /// Defaults to a sandbox-private `/work` (tmpfs, no host-side
289 /// state). Callers that need a host-visible work dir can swap in
290 /// [`Self::with_work_host`].
291 pub fn from_effects(effects: &EffectSet) -> Self {
292 let has_network = effects.iter().any(|e| matches!(e, Effect::Network));
293 Self {
294 ro_binds: vec![RoBind::new("/nix/store", "/nix/store")],
295 rw_binds: Vec::new(),
296 work_host: None,
297 network: has_network,
298 env_allowlist: vec![
299 "PATH".into(),
300 "HOME".into(),
301 "USER".into(),
302 "LANG".into(),
303 "LC_ALL".into(),
304 "LC_CTYPE".into(),
305 "NIX_PATH".into(),
306 "NIX_SSL_CERT_FILE".into(),
307 "SSL_CERT_FILE".into(),
308 "NOETHER_LOG_LEVEL".into(),
309 "RUST_LOG".into(),
310 ],
311 }
312 }
313
314 /// Override the sandbox's `/work` to bind a caller-provided host
315 /// directory. The directory must already exist and be writable by
316 /// the sandbox effective UID (65534). Consumers mostly leave the
317 /// default (tmpfs).
318 pub fn with_work_host(mut self, host: PathBuf) -> Self {
319 self.work_host = Some(host);
320 self
321 }
322}
323
324/// Conventional "nobody" UID/GID on Linux. bwrap maps the invoking
325/// user to this identity inside the sandbox so the stage cannot
326/// observe the real UID of the caller.
327pub const NOBODY_UID: u32 = 65534;
328pub const NOBODY_GID: u32 = 65534;
329
330/// Build a `bwrap` invocation that runs `cmd` inside a sandbox.
331///
332/// Returns a `Command` ready to spawn — the caller keeps ownership
333/// of stdin/stdout/stderr piping and waits on the child. The
334/// `work_host` path must exist; `bwrap` will fail otherwise.
335///
336/// Flags used (see bubblewrap(1)):
337///
338/// - `--unshare-all` — fresh user, pid, uts, ipc, mount, cgroup
339/// namespaces. Network namespace is unshared too, unless the
340/// policy re-shares via `--share-net` (see below).
341/// - `--uid 65534 --gid 65534` — map the invoking user to
342/// `nobody/nogroup` inside the sandbox. Without this, the stage
343/// would observe the host user's real UID (informational leak,
344/// and potentially exploitable when combined with filesystem
345/// bind-mount misconfiguration).
346/// - `--die-with-parent` — if the parent dies, so does the sandbox.
347/// - `--proc /proc`, `--dev /dev` — standard Linux mounts.
348/// - `--bind <host> <sandbox>` — writable bind mounts from the
349/// policy's `rw_binds`. Emitted **before** `ro_binds` so a
350/// narrower RO bind can shadow a broader RW parent — see the
351/// mount-order contract on [`IsolationPolicy::rw_binds`].
352/// - `--ro-bind <host> <sandbox>` — read-only mounts from the
353/// policy's `ro_binds`. Always includes `/nix/store`.
354/// - `--bind <work_host> /work` — writable scratch. Emitted last,
355/// so a `work_host` that sits under an earlier bind wins at `/work`.
356/// - `--chdir /work` — subprocess starts in the scratch dir.
357/// - `--clearenv` — wipe the environment; the executor re-adds the
358/// allowlisted variables via `.env(...)`.
359/// - `--share-net` — only when `policy.network` is true.
360/// - `--cap-drop ALL` — drop every capability inside the sandbox.
361pub fn build_bwrap_command(
362 bwrap: &Path,
363 policy: &IsolationPolicy,
364 inner_cmd: &[String],
365) -> Command {
366 let mut c = Command::new(bwrap);
367 c.arg("--unshare-all")
368 .arg("--die-with-parent")
369 .arg("--new-session")
370 .arg("--uid")
371 .arg(NOBODY_UID.to_string())
372 .arg("--gid")
373 .arg(NOBODY_GID.to_string())
374 .arg("--proc")
375 .arg("/proc")
376 .arg("--dev")
377 .arg("/dev")
378 .arg("--tmpfs")
379 .arg("/tmp")
380 .arg("--clearenv")
381 .arg("--cap-drop")
382 .arg("ALL");
383
384 if policy.network {
385 c.arg("--share-net");
386 // `--share-net` re-enters the host network namespace but the
387 // sandbox rootfs is otherwise empty. glibc NSS resolves DNS
388 // through `/etc/resolv.conf`, `/etc/nsswitch.conf`, and
389 // `/etc/hosts`; without those, even a correctly networked
390 // sandbox can't resolve hostnames. `--ro-bind-try` is a
391 // no-op when the source is absent (e.g. NixOS systems that
392 // route DNS differently), so it's safe to emit regardless.
393 //
394 // `/etc/ssl/certs` covers non-Nix-aware clients (curl,
395 // openssl, etc.) that expect the system trust store.
396 // Nix-built code uses `NIX_SSL_CERT_FILE` / `SSL_CERT_FILE`
397 // (already in the env allowlist) to point into `/nix/store`,
398 // which is bound separately.
399 for etc_path in [
400 "/etc/resolv.conf",
401 "/etc/hosts",
402 "/etc/nsswitch.conf",
403 "/etc/ssl/certs",
404 ] {
405 c.arg("--ro-bind-try").arg(etc_path).arg(etc_path);
406 }
407 }
408
409 // Mount-order contract (documented on IsolationPolicy::rw_binds):
410 // rw_binds → ro_binds → work_host. Emitting RW first lets a
411 // narrower RO entry shadow a broader RW parent — the
412 // "workdir RW, .ssh RO" pattern is the default-ergonomic case.
413 for bind in &policy.rw_binds {
414 c.arg("--bind").arg(&bind.host).arg(&bind.sandbox);
415 }
416
417 for bind in &policy.ro_binds {
418 c.arg("--ro-bind").arg(&bind.host).arg(&bind.sandbox);
419 }
420
421 match &policy.work_host {
422 Some(host) => {
423 c.arg("--bind").arg(host).arg("/work");
424 }
425 None => {
426 // Sandbox-private tmpfs at /work. No host-side path,
427 // so nothing to clean up and nothing for a host-side
428 // attacker to race into before the sandbox starts.
429 c.arg("--dir").arg("/work");
430 }
431 }
432 c.arg("--chdir").arg("/work");
433
434 // Env: `--clearenv` wipes the inner process's inherited env,
435 // then `--setenv` repopulates it. Setting `cmd.env(...)` on the
436 // outer `Command` would only affect `bwrap` itself, not the
437 // inner command — that was the trap the previous design fell
438 // into (HOME was set on bwrap but stripped before the stage
439 // ran, so `nix` crashed looking for a home directory).
440 //
441 // HOME / USER are always set to sandbox-consistent values
442 // (/work + "nobody" matching the UID mapping). Other allowlist
443 // entries inherit their value from the invoking process if set
444 // there.
445 c.arg("--setenv").arg("HOME").arg("/work");
446 c.arg("--setenv").arg("USER").arg("nobody");
447 for var in &policy.env_allowlist {
448 if var == "HOME" || var == "USER" {
449 continue;
450 }
451 if let Ok(v) = std::env::var(var) {
452 c.arg("--setenv").arg(var).arg(v);
453 }
454 }
455
456 c.arg("--").args(inner_cmd);
457 c
458}
459
460/// Locate the `bwrap` binary.
461///
462/// Checks a fixed list of trusted system paths first, because they're
463/// owned by root on every mainstream Linux distro and therefore can't
464/// be planted by a non-privileged attacker. Only if none of those
465/// exist does the search fall back to walking `$PATH` — at which
466/// point a `tracing::warn!` fires (once per process) so operators can
467/// notice that isolation is trusting an attacker-plantable lookup.
468///
469/// Returns `None` if `bwrap` is not installed anywhere we know to look.
470pub fn find_bwrap() -> Option<PathBuf> {
471 for trusted in TRUSTED_BWRAP_PATHS {
472 let candidate = PathBuf::from(trusted);
473 if candidate.is_file() {
474 return Some(candidate);
475 }
476 }
477
478 // Fallback: $PATH walk. Operators with a properly-provisioned
479 // host should never hit this branch; if they do, either `bwrap`
480 // was installed somewhere non-standard or the host's `$PATH` is
481 // pointing at attacker-writable directories (user shell rc files,
482 // container bind-mount mishaps, etc.).
483 let path_env = std::env::var_os("PATH")?;
484 for dir in std::env::split_paths(&path_env) {
485 let candidate = dir.join("bwrap");
486 if candidate.is_file() {
487 if !PATH_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
488 tracing::warn!(
489 resolved = %candidate.display(),
490 "bwrap resolved via $PATH — none of the trusted \
491 system paths contained it. If this host's PATH \
492 includes a user-writable directory, isolation can \
493 be trivially bypassed. Install bwrap to /usr/bin \
494 (distro package) or your system Nix profile."
495 );
496 }
497 return Some(candidate);
498 }
499 }
500 None
501}
502
503static PATH_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
504
505/// Root-owned locations where `bwrap` lives on a correctly-provisioned
506/// Linux host. Order matters: NixOS system profile first (nix hosts
507/// almost always have this), then the Determinate / single-user nix
508/// profile, then distro-packaged `/usr/bin`, then manual installs.
509///
510/// A non-root attacker can't write to any of these on a standard
511/// Linux system, so resolving through them short-circuits the
512/// `$PATH` planting vector. Linux-only: bwrap doesn't run on macOS
513/// or Windows, and typical macOS install paths (e.g. `/opt/homebrew`)
514/// are owned by the installing admin user, not root, so including
515/// them here would re-introduce the planting vector we're closing.
516pub const TRUSTED_BWRAP_PATHS: &[&str] = &[
517 "/run/current-system/sw/bin/bwrap",
518 "/nix/var/nix/profiles/default/bin/bwrap",
519 "/usr/bin/bwrap",
520 "/usr/local/bin/bwrap",
521];
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use noether_core::effects::{Effect, EffectSet};
527
528 #[test]
529 fn from_flag_parses_known_values() {
530 assert!(matches!(
531 IsolationBackend::from_flag("none").unwrap().0,
532 IsolationBackend::None
533 ));
534 assert!(IsolationBackend::from_flag("unknown").is_err());
535 }
536
537 #[test]
538 fn policy_without_network_effect_isolates_network() {
539 let effects = EffectSet::pure();
540 let policy = IsolationPolicy::from_effects(&effects);
541 assert!(!policy.network);
542 }
543
544 #[test]
545 fn policy_with_network_effect_shares_network() {
546 let effects = EffectSet::new([Effect::Pure, Effect::Network]);
547 let policy = IsolationPolicy::from_effects(&effects);
548 assert!(policy.network);
549 }
550
551 #[test]
552 fn policy_defaults_to_sandbox_private_work() {
553 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
554 assert!(
555 policy.work_host.is_none(),
556 "from_effects must default to sandbox-private /work; \
557 callers asking for host-visible scratch must opt in via \
558 .with_work_host(...)"
559 );
560 }
561
562 #[test]
563 fn policy_always_binds_nix_store() {
564 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
565 let bind = policy
566 .ro_binds
567 .iter()
568 .find(|b| b.sandbox == Path::new("/nix/store"))
569 .expect("nix store bind is missing");
570 assert_eq!(bind.host, Path::new("/nix/store"));
571 assert_eq!(bind.sandbox, Path::new("/nix/store"));
572 }
573
574 #[test]
575 fn bwrap_command_includes_core_flags() {
576 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
577 let cmd = build_bwrap_command(
578 Path::new("/usr/bin/bwrap"),
579 &policy,
580 &["python3".into(), "script.py".into()],
581 );
582 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
583
584 assert!(argv.contains(&"--unshare-all".to_string()));
585 assert!(argv.contains(&"--clearenv".to_string()));
586 assert!(argv.contains(&"--cap-drop".to_string()));
587 assert!(argv.contains(&"ALL".to_string()));
588 assert!(argv.contains(&"--die-with-parent".to_string()));
589 assert!(!argv.contains(&"--share-net".to_string()));
590 assert!(argv.contains(&"--dir".to_string()));
591 assert!(argv.contains(&"/work".to_string()));
592 let dash_dash_idx = argv
593 .iter()
594 .position(|a| a == "--")
595 .expect("missing -- separator");
596 assert_eq!(argv[dash_dash_idx + 1], "python3");
597 }
598
599 #[test]
600 fn bwrap_command_uses_host_bind_when_work_host_set() {
601 let policy = IsolationPolicy::from_effects(&EffectSet::pure())
602 .with_work_host(PathBuf::from("/tmp/inspect-me"));
603 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
604 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
605 let bind_pos = argv
606 .iter()
607 .position(|a| a == "--bind")
608 .expect("--bind missing");
609 assert_eq!(argv[bind_pos + 1], "/tmp/inspect-me");
610 assert_eq!(argv[bind_pos + 2], "/work");
611 }
612
613 #[test]
614 fn bwrap_command_adds_share_net_for_network_effect() {
615 let policy =
616 IsolationPolicy::from_effects(&EffectSet::new([Effect::Pure, Effect::Network]));
617 let cmd = build_bwrap_command(
618 Path::new("/usr/bin/bwrap"),
619 &policy,
620 &["curl".into(), "https://example.com".into()],
621 );
622 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
623 assert!(argv.contains(&"--share-net".to_string()));
624 }
625
626 #[test]
627 fn bwrap_command_maps_to_nobody_uid_and_gid() {
628 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
629 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
630 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
631
632 let uid_pos = argv
633 .iter()
634 .position(|a| a == "--uid")
635 .expect("--uid missing");
636 assert_eq!(argv[uid_pos + 1], "65534");
637 let gid_pos = argv
638 .iter()
639 .position(|a| a == "--gid")
640 .expect("--gid missing");
641 assert_eq!(argv[gid_pos + 1], "65534");
642 }
643
644 #[test]
645 fn trusted_bwrap_paths_are_root_owned_on_linux() {
646 for p in TRUSTED_BWRAP_PATHS {
647 assert!(
648 p.starts_with("/run/") || p.starts_with("/nix/var/") || p.starts_with("/usr/"),
649 "TRUSTED_BWRAP_PATHS entry '{p}' is not conventionally \
650 root-owned on Linux; only /run /nix/var /usr prefixes \
651 are permitted"
652 );
653 }
654 }
655
656 #[test]
657 fn effectiveness_predicate_matches_variant() {
658 assert!(!IsolationBackend::None.is_effective());
659 assert!(IsolationBackend::Bwrap {
660 bwrap_path: PathBuf::from("/usr/bin/bwrap"),
661 }
662 .is_effective());
663 }
664
665 #[test]
666 fn policy_round_trips_through_json() {
667 // Policy crosses a process boundary for consumers like the
668 // noether-sandbox binary (stdin JSON + argv). Pin the shape so
669 // a future field reorder / rename on the wire is deliberate.
670 //
671 // Exercises a non-empty rw_binds on purpose — the field landed
672 // in v0.7.2 behind #[serde(default)], and regressing it to
673 // "always empty on the wire" would silently strip caller
674 // trust-widening decisions without an error.
675 let mut policy = IsolationPolicy::from_effects(&EffectSet::pure())
676 .with_work_host(PathBuf::from("/tmp/work"));
677 policy
678 .rw_binds
679 .push(RwBind::new("/home/user/project", "/work/project"));
680 policy
681 .rw_binds
682 .push(RwBind::new("/tmp/output", "/tmp/output"));
683
684 let json = serde_json::to_string(&policy).unwrap();
685 let back: IsolationPolicy = serde_json::from_str(&json).unwrap();
686 assert_eq!(back.network, policy.network);
687 assert_eq!(back.work_host, policy.work_host);
688 assert_eq!(back.ro_binds, policy.ro_binds);
689 assert_eq!(back.rw_binds, policy.rw_binds);
690 assert_eq!(back.env_allowlist, policy.env_allowlist);
691 }
692
693 #[test]
694 fn rw_binds_default_to_empty_and_are_omitted_from_json() {
695 // #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")]
696 // is the back-compat contract: policies on the wire that predate
697 // v0.7.2 deserialise to an empty vec; policies with no rw_binds
698 // don't emit the field at all, keeping JSON compact.
699 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
700 assert!(
701 policy.rw_binds.is_empty(),
702 "from_effects must produce zero rw_binds; trust widening \
703 is opt-in"
704 );
705
706 let json = serde_json::to_string(&policy).unwrap();
707 assert!(
708 !json.contains("rw_binds"),
709 "empty rw_binds should not appear on the wire: {json}"
710 );
711
712 // A v0.7.1-shaped payload (no rw_binds field) must still
713 // deserialise cleanly.
714 let legacy = r#"{
715 "ro_binds": [{"host": "/nix/store", "sandbox": "/nix/store"}],
716 "network": false,
717 "env_allowlist": []
718 }"#;
719 let back: IsolationPolicy = serde_json::from_str(legacy).unwrap();
720 assert!(back.rw_binds.is_empty());
721 }
722
723 #[test]
724 fn bwrap_command_emits_rw_binds_before_ro_binds() {
725 // The mount-order contract documented on IsolationPolicy::rw_binds:
726 // rw → ro → work_host, so a narrower RO can shadow a broader
727 // RW parent. Pin this order at argv-emission time — reversing
728 // it would silently flip the shadowing semantics the doc
729 // promises callers.
730 let mut policy = IsolationPolicy::from_effects(&EffectSet::pure());
731 policy
732 .rw_binds
733 .push(RwBind::new("/home/user/project", "/work/project"));
734 policy
735 .ro_binds
736 .push(RoBind::new("/home/user/project/.ssh", "/work/project/.ssh"));
737 policy = policy.with_work_host(PathBuf::from("/tmp/workdir"));
738
739 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
740 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
741
742 // Find the first --bind (should be the RW project dir) and
743 // the --ro-bind for the .ssh subdir. RW index must come before
744 // RO index.
745 let rw_project_idx = argv
746 .windows(3)
747 .position(|w| w[0] == "--bind" && w[1] == "/home/user/project")
748 .expect("rw_binds entry must render as --bind <host> <sandbox>");
749 let ro_ssh_idx = argv
750 .windows(3)
751 .position(|w| w[0] == "--ro-bind" && w[1] == "/home/user/project/.ssh")
752 .expect("ro_binds entry must render as --ro-bind <host> <sandbox>");
753 assert!(
754 rw_project_idx < ro_ssh_idx,
755 "rw_binds must render before ro_binds so narrower RO shadows \
756 broader RW parent; got rw={rw_project_idx} ro={ro_ssh_idx}"
757 );
758
759 // work_host (/tmp/workdir → /work) must come after both so
760 // its mapping wins at /work.
761 let work_bind_idx = argv
762 .windows(3)
763 .position(|w| w[0] == "--bind" && w[1] == "/tmp/workdir" && w[2] == "/work")
764 .expect("work_host bind missing");
765 assert!(
766 work_bind_idx > ro_ssh_idx,
767 "work_host must render last so its /work mapping wins"
768 );
769 }
770}