use noether_core::effects::{Effect, EffectSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IsolationBackend {
None,
Bwrap { bwrap_path: PathBuf },
}
impl IsolationBackend {
pub fn auto() -> (Self, Option<String>) {
if let Some(path) = find_bwrap() {
return (IsolationBackend::Bwrap { bwrap_path: path }, None);
}
(
IsolationBackend::None,
Some(
"isolation backend 'auto' could not find bubblewrap \
(bwrap) on PATH; stage execution runs with full host-user \
privileges. Install bubblewrap (apt/brew/nix) to enable \
sandboxing, or pass --unsafe-no-isolation to silence \
this warning."
.into(),
),
)
}
pub fn from_flag(flag: &str) -> Result<(Self, Option<String>), IsolationError> {
match flag {
"auto" => Ok(Self::auto()),
"bwrap" => match find_bwrap() {
Some(path) => Ok((IsolationBackend::Bwrap { bwrap_path: path }, None)),
None => Err(IsolationError::BackendUnavailable {
backend: "bwrap".into(),
reason: "binary not found in PATH".into(),
}),
},
"none" => Ok((IsolationBackend::None, None)),
other => Err(IsolationError::UnknownBackend { name: other.into() }),
}
}
pub fn is_effective(&self) -> bool {
!matches!(self, IsolationBackend::None)
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum IsolationError {
#[error("isolation backend '{name}' is not recognised; expected one of: auto, bwrap, none")]
UnknownBackend { name: String },
#[error("isolation backend '{backend}' is unavailable: {reason}")]
BackendUnavailable { backend: String, reason: String },
}
#[derive(Debug, Clone)]
pub struct IsolationPolicy {
pub ro_binds: Vec<(PathBuf, PathBuf)>,
pub work_host: Option<PathBuf>,
pub network: bool,
pub env_allowlist: Vec<String>,
}
impl IsolationPolicy {
pub fn from_effects(effects: &EffectSet) -> Self {
let has_network = effects.iter().any(|e| matches!(e, Effect::Network));
Self {
ro_binds: vec![(PathBuf::from("/nix/store"), PathBuf::from("/nix/store"))],
work_host: None,
network: has_network,
env_allowlist: vec![
"PATH".into(),
"HOME".into(),
"USER".into(),
"LANG".into(),
"LC_ALL".into(),
"LC_CTYPE".into(),
"NIX_PATH".into(),
"NIX_SSL_CERT_FILE".into(),
"SSL_CERT_FILE".into(),
"NOETHER_LOG_LEVEL".into(),
"RUST_LOG".into(),
],
}
}
pub fn with_work_host(mut self, host: PathBuf) -> Self {
self.work_host = Some(host);
self
}
}
pub(crate) const NOBODY_UID: u32 = 65534;
pub(crate) const NOBODY_GID: u32 = 65534;
pub fn build_bwrap_command(
bwrap: &Path,
policy: &IsolationPolicy,
inner_cmd: &[String],
) -> Command {
let mut c = Command::new(bwrap);
c.arg("--unshare-all")
.arg("--die-with-parent")
.arg("--new-session")
.arg("--uid")
.arg(NOBODY_UID.to_string())
.arg("--gid")
.arg(NOBODY_GID.to_string())
.arg("--proc")
.arg("/proc")
.arg("--dev")
.arg("/dev")
.arg("--tmpfs")
.arg("/tmp")
.arg("--clearenv")
.arg("--cap-drop")
.arg("ALL");
if policy.network {
c.arg("--share-net");
for etc_path in [
"/etc/resolv.conf",
"/etc/hosts",
"/etc/nsswitch.conf",
"/etc/ssl/certs",
] {
c.arg("--ro-bind-try").arg(etc_path).arg(etc_path);
}
}
for (host, sandbox) in &policy.ro_binds {
c.arg("--ro-bind").arg(host).arg(sandbox);
}
match &policy.work_host {
Some(host) => {
c.arg("--bind").arg(host).arg("/work");
}
None => {
c.arg("--dir").arg("/work");
}
}
c.arg("--chdir").arg("/work");
c.arg("--setenv").arg("HOME").arg("/work");
c.arg("--setenv").arg("USER").arg("nobody");
for var in &policy.env_allowlist {
if var == "HOME" || var == "USER" {
continue;
}
if let Ok(v) = std::env::var(var) {
c.arg("--setenv").arg(var).arg(v);
}
}
c.arg("--").args(inner_cmd);
c
}
pub fn find_bwrap() -> Option<PathBuf> {
for trusted in TRUSTED_BWRAP_PATHS {
let candidate = PathBuf::from(trusted);
if candidate.is_file() {
return Some(candidate);
}
}
let path_env = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_env) {
let candidate = dir.join("bwrap");
if candidate.is_file() {
if !PATH_FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
resolved = %candidate.display(),
"bwrap resolved via $PATH — none of the trusted \
system paths contained it. If this host's PATH \
includes a user-writable directory, isolation can \
be trivially bypassed. Install bwrap to /usr/bin \
(distro package) or your system Nix profile."
);
}
return Some(candidate);
}
}
None
}
static PATH_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
pub(crate) const TRUSTED_BWRAP_PATHS: &[&str] = &[
"/run/current-system/sw/bin/bwrap",
"/nix/var/nix/profiles/default/bin/bwrap",
"/usr/bin/bwrap",
"/usr/local/bin/bwrap",
];
#[cfg(test)]
mod tests {
use super::*;
use noether_core::effects::{Effect, EffectSet};
#[test]
fn from_flag_parses_known_values() {
assert!(matches!(
IsolationBackend::from_flag("none").unwrap().0,
IsolationBackend::None
));
assert!(IsolationBackend::from_flag("unknown").is_err());
}
#[test]
fn policy_without_network_effect_isolates_network() {
let effects = EffectSet::pure();
let policy = IsolationPolicy::from_effects(&effects);
assert!(!policy.network);
}
#[test]
fn policy_with_network_effect_shares_network() {
let effects = EffectSet::new([Effect::Pure, Effect::Network]);
let policy = IsolationPolicy::from_effects(&effects);
assert!(policy.network);
}
#[test]
fn policy_defaults_to_sandbox_private_work() {
let policy = IsolationPolicy::from_effects(&EffectSet::pure());
assert!(
policy.work_host.is_none(),
"from_effects must default to sandbox-private /work; \
callers asking for host-visible scratch must opt in via \
.with_work_host(...)"
);
}
#[test]
fn policy_always_binds_nix_store() {
let policy = IsolationPolicy::from_effects(&EffectSet::pure());
let (host, sandbox) = policy
.ro_binds
.iter()
.find(|(_, s)| s == Path::new("/nix/store"))
.expect("nix store bind is missing");
assert_eq!(host, Path::new("/nix/store"));
assert_eq!(sandbox, Path::new("/nix/store"));
}
#[test]
fn bwrap_command_includes_core_flags() {
let policy = IsolationPolicy::from_effects(&EffectSet::pure());
let cmd = build_bwrap_command(
Path::new("/usr/bin/bwrap"),
&policy,
&["python3".into(), "script.py".into()],
);
let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
assert!(argv.contains(&"--unshare-all".to_string()));
assert!(argv.contains(&"--clearenv".to_string()));
assert!(argv.contains(&"--cap-drop".to_string()));
assert!(argv.contains(&"ALL".to_string()));
assert!(argv.contains(&"--die-with-parent".to_string()));
assert!(!argv.contains(&"--share-net".to_string()));
assert!(argv.contains(&"--dir".to_string()));
assert!(argv.contains(&"/work".to_string()));
let dash_dash_idx = argv
.iter()
.position(|a| a == "--")
.expect("missing -- separator");
assert_eq!(argv[dash_dash_idx + 1], "python3");
}
#[test]
fn bwrap_command_uses_host_bind_when_work_host_set() {
let policy = IsolationPolicy::from_effects(&EffectSet::pure())
.with_work_host(PathBuf::from("/tmp/inspect-me"));
let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
let bind_pos = argv
.iter()
.position(|a| a == "--bind")
.expect("--bind missing");
assert_eq!(argv[bind_pos + 1], "/tmp/inspect-me");
assert_eq!(argv[bind_pos + 2], "/work");
}
#[test]
fn bwrap_command_adds_share_net_for_network_effect() {
let policy =
IsolationPolicy::from_effects(&EffectSet::new([Effect::Pure, Effect::Network]));
let cmd = build_bwrap_command(
Path::new("/usr/bin/bwrap"),
&policy,
&["curl".into(), "https://example.com".into()],
);
let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
assert!(argv.contains(&"--share-net".to_string()));
}
#[test]
fn bwrap_command_maps_to_nobody_uid_and_gid() {
let policy = IsolationPolicy::from_effects(&EffectSet::pure());
let cmd = build_bwrap_command(Path::new("/usr/bin/bwrap"), &policy, &["python3".into()]);
let argv: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into()).collect();
let uid_pos = argv
.iter()
.position(|a| a == "--uid")
.expect("--uid missing");
assert_eq!(argv[uid_pos + 1], "65534");
let gid_pos = argv
.iter()
.position(|a| a == "--gid")
.expect("--gid missing");
assert_eq!(argv[gid_pos + 1], "65534");
}
#[test]
fn trusted_bwrap_paths_are_root_owned_on_linux() {
for p in TRUSTED_BWRAP_PATHS {
assert!(
p.starts_with("/run/") || p.starts_with("/nix/var/") || p.starts_with("/usr/"),
"TRUSTED_BWRAP_PATHS entry '{p}' is not conventionally \
root-owned on Linux; only /run /nix/var /usr prefixes \
are permitted"
);
}
}
#[test]
fn effectiveness_predicate_matches_variant() {
assert!(!IsolationBackend::None.is_effective());
assert!(IsolationBackend::Bwrap {
bwrap_path: PathBuf::from("/usr/bin/bwrap"),
}
.is_effective());
}
}