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 // M3.x: path-scoped filesystem effects drive bind-mount
294 // generation. `Effect::FsRead(p)` → `RoBind { p, p }`,
295 // `Effect::FsWrite(p)` → `RwBind { p, p }`. Multiple entries
296 // of each variant produce multiple binds. `/nix/store` is
297 // always bound read-only because Nix-pinned runtimes need to
298 // resolve regardless of declared effects.
299 let mut ro_binds = vec![RoBind::new("/nix/store", "/nix/store")];
300 let mut rw_binds = Vec::new();
301 for effect in effects.iter() {
302 match effect {
303 Effect::FsRead { path } => ro_binds.push(RoBind::new(path.clone(), path.clone())),
304 Effect::FsWrite { path } => rw_binds.push(RwBind::new(path.clone(), path.clone())),
305 _ => {}
306 }
307 }
308 Self {
309 ro_binds,
310 rw_binds,
311 work_host: None,
312 network: has_network,
313 env_allowlist: vec![
314 "PATH".into(),
315 "HOME".into(),
316 "USER".into(),
317 "LANG".into(),
318 "LC_ALL".into(),
319 "LC_CTYPE".into(),
320 "NIX_PATH".into(),
321 "NIX_SSL_CERT_FILE".into(),
322 "SSL_CERT_FILE".into(),
323 "NOETHER_LOG_LEVEL".into(),
324 "RUST_LOG".into(),
325 ],
326 }
327 }
328
329 /// Override the sandbox's `/work` to bind a caller-provided host
330 /// directory. The directory must already exist and be writable by
331 /// the sandbox effective UID (65534). Consumers mostly leave the
332 /// default (tmpfs).
333 pub fn with_work_host(mut self, host: PathBuf) -> Self {
334 self.work_host = Some(host);
335 self
336 }
337}
338
339/// Conventional "nobody" UID/GID on Linux. bwrap maps the invoking
340/// user to this identity inside the sandbox so the stage cannot
341/// observe the real UID of the caller.
342pub const NOBODY_UID: u32 = 65534;
343pub const NOBODY_GID: u32 = 65534;
344
345/// Build a `bwrap` invocation that runs `cmd` inside a sandbox.
346///
347/// Returns a `Command` ready to spawn — the caller keeps ownership
348/// of stdin/stdout/stderr piping and waits on the child. The
349/// `work_host` path must exist; `bwrap` will fail otherwise.
350///
351/// Flags used (see bubblewrap(1)):
352///
353/// - `--unshare-all` — fresh user, pid, uts, ipc, mount, cgroup
354/// namespaces. Network namespace is unshared too, unless the
355/// policy re-shares via `--share-net` (see below).
356/// - `--uid 65534 --gid 65534` — map the invoking user to
357/// `nobody/nogroup` inside the sandbox. Without this, the stage
358/// would observe the host user's real UID (informational leak,
359/// and potentially exploitable when combined with filesystem
360/// bind-mount misconfiguration).
361/// - `--die-with-parent` — if the parent dies, so does the sandbox.
362/// - `--proc /proc`, `--dev /dev` — standard Linux mounts.
363/// - `--bind <host> <sandbox>` — writable bind mounts from the
364/// policy's `rw_binds`. Emitted **before** `ro_binds` so a
365/// narrower RO bind can shadow a broader RW parent — see the
366/// mount-order contract on [`IsolationPolicy::rw_binds`].
367/// - `--ro-bind <host> <sandbox>` — read-only mounts from the
368/// policy's `ro_binds`. Always includes `/nix/store`.
369/// - `--bind <work_host> /work` — writable scratch. Emitted last,
370/// so a `work_host` that sits under an earlier bind wins at `/work`.
371/// - `--chdir /work` — subprocess starts in the scratch dir.
372/// - `--clearenv` — wipe the environment; the executor re-adds the
373/// allowlisted variables via `.env(...)`.
374/// - `--share-net` — only when `policy.network` is true.
375/// - `--cap-drop ALL` — drop every capability inside the sandbox.
376pub fn build_bwrap_command(
377 bwrap: &Path,
378 policy: &IsolationPolicy,
379 inner_cmd: &[String],
380) -> Command {
381 let mut c = Command::new(bwrap);
382 c.arg("--unshare-all")
383 .arg("--die-with-parent")
384 .arg("--new-session")
385 .arg("--uid")
386 .arg(NOBODY_UID.to_string())
387 .arg("--gid")
388 .arg(NOBODY_GID.to_string())
389 .arg("--proc")
390 .arg("/proc")
391 .arg("--dev")
392 .arg("/dev")
393 .arg("--tmpfs")
394 .arg("/tmp")
395 .arg("--clearenv")
396 .arg("--cap-drop")
397 .arg("ALL");
398
399 if policy.network {
400 c.arg("--share-net");
401 // `--share-net` re-enters the host network namespace but the
402 // sandbox rootfs is otherwise empty. glibc NSS resolves DNS
403 // through `/etc/resolv.conf`, `/etc/nsswitch.conf`, and
404 // `/etc/hosts`; without those, even a correctly networked
405 // sandbox can't resolve hostnames. `--ro-bind-try` is a
406 // no-op when the source is absent (e.g. NixOS systems that
407 // route DNS differently), so it's safe to emit regardless.
408 //
409 // `/etc/ssl/certs` covers non-Nix-aware clients (curl,
410 // openssl, etc.) that expect the system trust store.
411 // Nix-built code uses `NIX_SSL_CERT_FILE` / `SSL_CERT_FILE`
412 // (already in the env allowlist) to point into `/nix/store`,
413 // which is bound separately.
414 for etc_path in [
415 "/etc/resolv.conf",
416 "/etc/hosts",
417 "/etc/nsswitch.conf",
418 "/etc/ssl/certs",
419 ] {
420 c.arg("--ro-bind-try").arg(etc_path).arg(etc_path);
421 }
422 }
423
424 // Mount-order contract (documented on IsolationPolicy::rw_binds):
425 // rw_binds → ro_binds → work_host. Emitting RW first lets a
426 // narrower RO entry shadow a broader RW parent — the
427 // "workdir RW, .ssh RO" pattern is the default-ergonomic case.
428 for bind in &policy.rw_binds {
429 c.arg("--bind").arg(&bind.host).arg(&bind.sandbox);
430 }
431
432 for bind in &policy.ro_binds {
433 c.arg("--ro-bind").arg(&bind.host).arg(&bind.sandbox);
434 }
435
436 match &policy.work_host {
437 Some(host) => {
438 c.arg("--bind").arg(host).arg("/work");
439 }
440 None => {
441 // Sandbox-private tmpfs at /work. No host-side path,
442 // so nothing to clean up and nothing for a host-side
443 // attacker to race into before the sandbox starts.
444 c.arg("--dir").arg("/work");
445 }
446 }
447 c.arg("--chdir").arg("/work");
448
449 // Env: `--clearenv` wipes the inner process's inherited env,
450 // then `--setenv` repopulates it. Setting `cmd.env(...)` on the
451 // outer `Command` would only affect `bwrap` itself, not the
452 // inner command — that was the trap the previous design fell
453 // into (HOME was set on bwrap but stripped before the stage
454 // ran, so `nix` crashed looking for a home directory).
455 //
456 // HOME / USER are always set to sandbox-consistent values
457 // (/work + "nobody" matching the UID mapping). Other allowlist
458 // entries inherit their value from the invoking process if set
459 // there.
460 c.arg("--setenv").arg("HOME").arg("/work");
461 c.arg("--setenv").arg("USER").arg("nobody");
462 for var in &policy.env_allowlist {
463 if var == "HOME" || var == "USER" {
464 continue;
465 }
466 if let Ok(v) = std::env::var(var) {
467 c.arg("--setenv").arg(var).arg(v);
468 }
469 }
470
471 c.arg("--").args(inner_cmd);
472 c
473}
474
475/// Locate the `bwrap` binary.
476///
477/// Checks a fixed list of trusted system paths first, because they're
478/// owned by root on every mainstream Linux distro and therefore can't
479/// be planted by a non-privileged attacker. Only if none of those
480/// exist does the search fall back to walking `$PATH` — at which
481/// point a `tracing::warn!` fires (once per process) so operators can
482/// notice that isolation is trusting an attacker-plantable lookup.
483///
484/// Returns `None` if `bwrap` is not installed anywhere we know to look.
485pub fn find_bwrap() -> Option<PathBuf> {
486 for trusted in TRUSTED_BWRAP_PATHS {
487 let candidate = PathBuf::from(trusted);
488 if candidate.is_file() {
489 return Some(candidate);
490 }
491 }
492
493 // Fallback: $PATH walk. Operators with a properly-provisioned
494 // host should never hit this branch; if they do, either `bwrap`
495 // was installed somewhere non-standard or the host's `$PATH` is
496 // pointing at attacker-writable directories (user shell rc files,
497 // container bind-mount mishaps, etc.).
498 let path_env = std::env::var_os("PATH")?;
499 for dir in std::env::split_paths(&path_env) {
500 let candidate = dir.join("bwrap");
501 if candidate.is_file() {
502 if !PATH_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
503 tracing::warn!(
504 resolved = %candidate.display(),
505 "bwrap resolved via $PATH — none of the trusted \
506 system paths contained it. If this host's PATH \
507 includes a user-writable directory, isolation can \
508 be trivially bypassed. Install bwrap to /usr/bin \
509 (distro package) or your system Nix profile."
510 );
511 }
512 return Some(candidate);
513 }
514 }
515 None
516}
517
518static PATH_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
519
520/// Root-owned locations where `bwrap` lives on a correctly-provisioned
521/// Linux host. Order matters: NixOS system profile first (nix hosts
522/// almost always have this), then the Determinate / single-user nix
523/// profile, then distro-packaged `/usr/bin`, then manual installs.
524///
525/// A non-root attacker can't write to any of these on a standard
526/// Linux system, so resolving through them short-circuits the
527/// `$PATH` planting vector. Linux-only: bwrap doesn't run on macOS
528/// or Windows, and typical macOS install paths (e.g. `/opt/homebrew`)
529/// are owned by the installing admin user, not root, so including
530/// them here would re-introduce the planting vector we're closing.
531pub const TRUSTED_BWRAP_PATHS: &[&str] = &[
532 "/run/current-system/sw/bin/bwrap",
533 "/nix/var/nix/profiles/default/bin/bwrap",
534 "/usr/bin/bwrap",
535 "/usr/local/bin/bwrap",
536];
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use noether_core::effects::{Effect, EffectSet};
542
543 #[test]
544 fn from_flag_parses_known_values() {
545 assert!(matches!(
546 IsolationBackend::from_flag("none").unwrap().0,
547 IsolationBackend::None
548 ));
549 assert!(IsolationBackend::from_flag("unknown").is_err());
550 }
551
552 #[test]
553 fn policy_without_network_effect_isolates_network() {
554 let effects = EffectSet::pure();
555 let policy = IsolationPolicy::from_effects(&effects);
556 assert!(!policy.network);
557 }
558
559 #[test]
560 fn policy_with_network_effect_shares_network() {
561 let effects = EffectSet::new([Effect::Pure, Effect::Network]);
562 let policy = IsolationPolicy::from_effects(&effects);
563 assert!(policy.network);
564 }
565
566 #[test]
567 fn policy_defaults_to_sandbox_private_work() {
568 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
569 assert!(
570 policy.work_host.is_none(),
571 "from_effects must default to sandbox-private /work; \
572 callers asking for host-visible scratch must opt in via \
573 .with_work_host(...)"
574 );
575 }
576
577 #[test]
578 fn policy_always_binds_nix_store() {
579 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
580 let bind = policy
581 .ro_binds
582 .iter()
583 .find(|b| b.sandbox == Path::new("/nix/store"))
584 .expect("nix store bind is missing");
585 assert_eq!(bind.host, Path::new("/nix/store"));
586 assert_eq!(bind.sandbox, Path::new("/nix/store"));
587 }
588
589 #[test]
590 fn bwrap_command_includes_core_flags() {
591 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
592 let cmd = build_bwrap_command(
593 Path::new("/usr/bin/bwrap"),
594 &policy,
595 &["python3".into(), "script.py".into()],
596 );
597 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
598
599 assert!(argv.contains(&"--unshare-all".to_string()));
600 assert!(argv.contains(&"--clearenv".to_string()));
601 assert!(argv.contains(&"--cap-drop".to_string()));
602 assert!(argv.contains(&"ALL".to_string()));
603 assert!(argv.contains(&"--die-with-parent".to_string()));
604 assert!(!argv.contains(&"--share-net".to_string()));
605 assert!(argv.contains(&"--dir".to_string()));
606 assert!(argv.contains(&"/work".to_string()));
607 let dash_dash_idx = argv
608 .iter()
609 .position(|a| a == "--")
610 .expect("missing -- separator");
611 assert_eq!(argv[dash_dash_idx + 1], "python3");
612 }
613
614 #[test]
615 fn bwrap_command_uses_host_bind_when_work_host_set() {
616 let policy = IsolationPolicy::from_effects(&EffectSet::pure())
617 .with_work_host(PathBuf::from("/tmp/inspect-me"));
618 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
619 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
620 let bind_pos = argv
621 .iter()
622 .position(|a| a == "--bind")
623 .expect("--bind missing");
624 assert_eq!(argv[bind_pos + 1], "/tmp/inspect-me");
625 assert_eq!(argv[bind_pos + 2], "/work");
626 }
627
628 #[test]
629 fn bwrap_command_adds_share_net_for_network_effect() {
630 let policy =
631 IsolationPolicy::from_effects(&EffectSet::new([Effect::Pure, Effect::Network]));
632 let cmd = build_bwrap_command(
633 Path::new("/usr/bin/bwrap"),
634 &policy,
635 &["curl".into(), "https://example.com".into()],
636 );
637 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
638 assert!(argv.contains(&"--share-net".to_string()));
639 }
640
641 #[test]
642 fn bwrap_command_maps_to_nobody_uid_and_gid() {
643 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
644 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
645 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
646
647 let uid_pos = argv
648 .iter()
649 .position(|a| a == "--uid")
650 .expect("--uid missing");
651 assert_eq!(argv[uid_pos + 1], "65534");
652 let gid_pos = argv
653 .iter()
654 .position(|a| a == "--gid")
655 .expect("--gid missing");
656 assert_eq!(argv[gid_pos + 1], "65534");
657 }
658
659 #[test]
660 fn trusted_bwrap_paths_are_root_owned_on_linux() {
661 for p in TRUSTED_BWRAP_PATHS {
662 assert!(
663 p.starts_with("/run/") || p.starts_with("/nix/var/") || p.starts_with("/usr/"),
664 "TRUSTED_BWRAP_PATHS entry '{p}' is not conventionally \
665 root-owned on Linux; only /run /nix/var /usr prefixes \
666 are permitted"
667 );
668 }
669 }
670
671 #[test]
672 fn effectiveness_predicate_matches_variant() {
673 assert!(!IsolationBackend::None.is_effective());
674 assert!(IsolationBackend::Bwrap {
675 bwrap_path: PathBuf::from("/usr/bin/bwrap"),
676 }
677 .is_effective());
678 }
679
680 #[test]
681 fn policy_round_trips_through_json() {
682 // Policy crosses a process boundary for consumers like the
683 // noether-sandbox binary (stdin JSON + argv). Pin the shape so
684 // a future field reorder / rename on the wire is deliberate.
685 //
686 // Exercises a non-empty rw_binds on purpose — the field landed
687 // in v0.7.2 behind #[serde(default)], and regressing it to
688 // "always empty on the wire" would silently strip caller
689 // trust-widening decisions without an error.
690 let mut policy = IsolationPolicy::from_effects(&EffectSet::pure())
691 .with_work_host(PathBuf::from("/tmp/work"));
692 policy
693 .rw_binds
694 .push(RwBind::new("/home/user/project", "/work/project"));
695 policy
696 .rw_binds
697 .push(RwBind::new("/tmp/output", "/tmp/output"));
698
699 let json = serde_json::to_string(&policy).unwrap();
700 let back: IsolationPolicy = serde_json::from_str(&json).unwrap();
701 assert_eq!(back.network, policy.network);
702 assert_eq!(back.work_host, policy.work_host);
703 assert_eq!(back.ro_binds, policy.ro_binds);
704 assert_eq!(back.rw_binds, policy.rw_binds);
705 assert_eq!(back.env_allowlist, policy.env_allowlist);
706 }
707
708 #[test]
709 fn rw_binds_default_to_empty_and_are_omitted_from_json() {
710 // #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")]
711 // is the back-compat contract: policies on the wire that predate
712 // v0.7.2 deserialise to an empty vec; policies with no rw_binds
713 // don't emit the field at all, keeping JSON compact.
714 let policy = IsolationPolicy::from_effects(&EffectSet::pure());
715 assert!(
716 policy.rw_binds.is_empty(),
717 "from_effects must produce zero rw_binds; trust widening \
718 is opt-in"
719 );
720
721 let json = serde_json::to_string(&policy).unwrap();
722 assert!(
723 !json.contains("rw_binds"),
724 "empty rw_binds should not appear on the wire: {json}"
725 );
726
727 // A v0.7.1-shaped payload (no rw_binds field) must still
728 // deserialise cleanly.
729 let legacy = r#"{
730 "ro_binds": [{"host": "/nix/store", "sandbox": "/nix/store"}],
731 "network": false,
732 "env_allowlist": []
733 }"#;
734 let back: IsolationPolicy = serde_json::from_str(legacy).unwrap();
735 assert!(back.rw_binds.is_empty());
736 }
737
738 #[test]
739 fn bwrap_command_emits_rw_binds_before_ro_binds() {
740 // The mount-order contract documented on IsolationPolicy::rw_binds:
741 // rw → ro → work_host, so a narrower RO can shadow a broader
742 // RW parent. Pin this order at argv-emission time — reversing
743 // it would silently flip the shadowing semantics the doc
744 // promises callers.
745 let mut policy = IsolationPolicy::from_effects(&EffectSet::pure());
746 policy
747 .rw_binds
748 .push(RwBind::new("/home/user/project", "/work/project"));
749 policy
750 .ro_binds
751 .push(RoBind::new("/home/user/project/.ssh", "/work/project/.ssh"));
752 policy = policy.with_work_host(PathBuf::from("/tmp/workdir"));
753
754 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
755 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
756
757 // Find the first --bind (should be the RW project dir) and
758 // the --ro-bind for the .ssh subdir. RW index must come before
759 // RO index.
760 let rw_project_idx = argv
761 .windows(3)
762 .position(|w| w[0] == "--bind" && w[1] == "/home/user/project")
763 .expect("rw_binds entry must render as --bind <host> <sandbox>");
764 let ro_ssh_idx = argv
765 .windows(3)
766 .position(|w| w[0] == "--ro-bind" && w[1] == "/home/user/project/.ssh")
767 .expect("ro_binds entry must render as --ro-bind <host> <sandbox>");
768 assert!(
769 rw_project_idx < ro_ssh_idx,
770 "rw_binds must render before ro_binds so narrower RO shadows \
771 broader RW parent; got rw={rw_project_idx} ro={ro_ssh_idx}"
772 );
773
774 // work_host (/tmp/workdir → /work) must come after both so
775 // its mapping wins at /work.
776 let work_bind_idx = argv
777 .windows(3)
778 .position(|w| w[0] == "--bind" && w[1] == "/tmp/workdir" && w[2] == "/work")
779 .expect("work_host bind missing");
780 assert!(
781 work_bind_idx > ro_ssh_idx,
782 "work_host must render last so its /work mapping wins"
783 );
784 }
785
786 #[test]
787 fn fs_read_effect_becomes_ro_bind() {
788 // M3.x: a stage declaring `Effect::FsRead(p)` should see `p`
789 // bound read-only at the same path inside the sandbox, no
790 // caller intervention required. Pairs with the CHANGELOG's
791 // "effects drive policy" framing.
792 let policy = IsolationPolicy::from_effects(&EffectSet::new([
793 Effect::Pure,
794 Effect::FsRead {
795 path: PathBuf::from("/etc/ssl/certs"),
796 },
797 ]));
798 let bound = policy
799 .ro_binds
800 .iter()
801 .find(|b| b.host == Path::new("/etc/ssl/certs"))
802 .expect("FsRead path must appear in ro_binds");
803 assert_eq!(bound.sandbox, Path::new("/etc/ssl/certs"));
804 assert!(policy.rw_binds.is_empty(), "FsRead must not trigger RW");
805 }
806
807 #[test]
808 fn fs_write_effect_becomes_rw_bind() {
809 let policy = IsolationPolicy::from_effects(&EffectSet::new([
810 Effect::Pure,
811 Effect::FsWrite {
812 path: PathBuf::from("/tmp/agent-output"),
813 },
814 ]));
815 let bound = policy
816 .rw_binds
817 .iter()
818 .find(|b| b.host == Path::new("/tmp/agent-output"))
819 .expect("FsWrite path must appear in rw_binds");
820 assert_eq!(bound.sandbox, Path::new("/tmp/agent-output"));
821 // /nix/store is still in ro_binds; FsWrite shouldn't disturb
822 // the baseline read-only mounts.
823 assert!(policy
824 .ro_binds
825 .iter()
826 .any(|b| b.sandbox == Path::new("/nix/store")));
827 }
828
829 #[test]
830 fn multiple_fs_effects_produce_multiple_binds() {
831 // A stage can declare several FsRead / FsWrite paths. Each
832 // becomes its own bind entry — the BTreeSet semantics of
833 // EffectSet keep distinct-path effects distinct.
834 let policy = IsolationPolicy::from_effects(&EffectSet::new([
835 Effect::FsRead {
836 path: PathBuf::from("/etc"),
837 },
838 Effect::FsRead {
839 path: PathBuf::from("/usr/share"),
840 },
841 Effect::FsWrite {
842 path: PathBuf::from("/tmp/out"),
843 },
844 Effect::FsWrite {
845 path: PathBuf::from("/var/log/agent"),
846 },
847 ]));
848 let ro_paths: Vec<&Path> = policy.ro_binds.iter().map(|b| b.host.as_path()).collect();
849 assert!(ro_paths.contains(&Path::new("/etc")));
850 assert!(ro_paths.contains(&Path::new("/usr/share")));
851 assert!(ro_paths.contains(&Path::new("/nix/store")));
852 let rw_paths: Vec<&Path> = policy.rw_binds.iter().map(|b| b.host.as_path()).collect();
853 assert!(rw_paths.contains(&Path::new("/tmp/out")));
854 assert!(rw_paths.contains(&Path::new("/var/log/agent")));
855 }
856
857 #[test]
858 fn bwrap_command_emits_fs_effect_binds() {
859 // End-to-end: FsRead / FsWrite declared in the effect set
860 // should show up as --ro-bind / --bind argv entries. This
861 // pins the full pipeline from Effect → Policy → argv.
862 let policy = IsolationPolicy::from_effects(&EffectSet::new([
863 Effect::FsRead {
864 path: PathBuf::from("/etc/ssl/certs"),
865 },
866 Effect::FsWrite {
867 path: PathBuf::from("/tmp/agent-output"),
868 },
869 ]));
870 let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
871 let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
872
873 let ro_idx = argv
874 .windows(3)
875 .position(|w| w[0] == "--ro-bind" && w[1] == "/etc/ssl/certs")
876 .expect("FsRead should render as --ro-bind");
877 let rw_idx = argv
878 .windows(3)
879 .position(|w| w[0] == "--bind" && w[1] == "/tmp/agent-output")
880 .expect("FsWrite should render as --bind");
881 // Mount-order contract still holds with effect-driven binds.
882 assert!(
883 rw_idx < ro_idx,
884 "rw_binds must still render before ro_binds when both come from effects"
885 );
886 }
887}