diff --git a/nash-observe/src/lib.rs b/nash-observe/src/lib.rs index 4fd8a5f..c95aa9f 100644 --- a/nash-observe/src/lib.rs +++ b/nash-observe/src/lib.rs @@ -105,6 +105,20 @@ enum Event { }, #[serde(rename = "dropped", rename_all = "camelCase")] Dropped { seq: u64, ts: u64, count: u64 }, + /// An operator lever nash was asked to act on (docs/NASH.md §4.3). `action` is + /// `ignored` for a lever that arrived through the environment — the channel the + /// observed process controls, which nash refuses — and `honored` for one the + /// trusted policy file set. Either way it becomes a feed row, so a shell going + /// dark is a visible event rather than an absence of events. + #[serde(rename = "policy", rename_all = "camelCase")] + Policy { + seq: u64, + ts: u64, + lever: String, + value: String, + action: String, + note: String, + }, } /// The pipeline stage an exec event belongs to (docs/NASH.md §5.1). `id` is the same @@ -181,17 +195,31 @@ fn parse_hook_url(url: &str) -> Option { } impl Config { - fn from_env() -> Option { - if std::env::var("NUCLEIC_SHELL_CAPTURE").as_deref() == Ok("off") { + /// Read transport config from the environment. + /// + /// `require_observation` comes from the trusted policy file (docs/NASH.md §4.3) and + /// closes the quietest bypass there was: `NUCLEIC_SHELL_CAPTURE=off`, or simply + /// `env -u NUCLEIC_SHELL_HOOK_URL …`, used to leave nash running as an ordinary + /// shell that reported nothing — no fallback event, no trace, from a process that + /// merely rewrote its own environment. Under the policy, `off` from the environment + /// is refused and a missing transport falls back to `fallback_spool`, which the + /// host's spool drain already collects. An operator who genuinely wants a surface + /// silent turns the policy's `require_observation` off (or omits the file) rather + /// than setting a variable the agent could have set for them. + fn from_env(require_observation: bool, fallback_spool: &Path) -> Option { + if std::env::var("NUCLEIC_SHELL_CAPTURE").as_deref() == Ok("off") && !require_observation { return None; } let socket = std::env::var_os("NUCLEIC_SHELL_SOCKET").map(PathBuf::from); let hook_url = std::env::var("NUCLEIC_SHELL_HOOK_URL") .ok() .and_then(|u| parse_hook_url(&u)); - let spool = std::env::var_os("NUCLEIC_SHELL_SPOOL").map(PathBuf::from); + let mut spool = std::env::var_os("NUCLEIC_SHELL_SPOOL").map(PathBuf::from); if socket.is_none() && hook_url.is_none() && spool.is_none() { - return None; + if !require_observation { + return None; + } + spool = Some(fallback_spool.to_path_buf()); } Some(Self { socket, @@ -716,10 +744,14 @@ fn flusher(cfg: Config, shell_id: String, parent_shell: Option, rx: mpsc /// configured, installs the recording gate into brush-core and starts the /// flusher thread. Returns whether observation is active. /// +/// `require_observation` / `fallback_spool` come from the trusted policy file +/// (docs/NASH.md §4.3): with it set, an environment that says "don't observe" +/// no longer wins, because that environment belongs to the observed process. +/// /// Also threads shell lineage: reads `NUCLEIC_SHELL_PARENT` as this shell's /// parent and re-exports it as this shell's own id for child shells. -pub fn install_from_env() -> bool { - let Some(cfg) = Config::from_env() else { +pub fn install_from_env(require_observation: bool, fallback_spool: &Path) -> bool { + let Some(cfg) = Config::from_env(require_observation, fallback_spool) else { return false; }; @@ -759,6 +791,24 @@ pub fn flush_sync() { } } +/// Records an operator-lever event (docs/NASH.md §4.3): a lever nash refused to take +/// from the environment (`action: "ignored"`) or one the trusted policy set +/// (`action: "honored"`). Not flushed here — callers that are about to `exec` away +/// call [`flush_sync`] themselves, and the ordinary path flushes at exit. +pub fn report_policy(lever: &str, value: &str, action: &str, note: &str) { + let Some(obs) = OBSERVER.get() else { return }; + obs.send(Event::Policy { + seq: obs.seq(), + ts: now_millis(), + lever: lever.to_string(), + // Bounded like any other agent-authored string on this path: the value is + // whatever the command line put there. + value: value.chars().take(512).collect(), + action: action.to_string(), + note: note.to_string(), + }); +} + /// Records a compat-fallback event (docs/NASH.md §4.1): nash is about to /// re-exec the real bash because it could not handle `input`. pub fn report_fallback(reason: &str, input: &str) { diff --git a/nash/src/main.rs b/nash/src/main.rs index e825001..202e911 100644 --- a/nash/src/main.rs +++ b/nash/src/main.rs @@ -1,26 +1,53 @@ //! nash — the Nucleic agent shell (see docs/NASH.md). //! -//! A thin binary around the vendored brush fork: installs the `nash-observe` -//! recording gate (when `NUCLEIC_SHELL_*` config is present), registers the -//! exit-time event flush, applies the compat fallback for `-c` input brush -//! cannot parse, and otherwise defers entirely to brush's bash-compatible CLI. +//! A thin binary around the vendored brush fork: loads the trusted operator policy +//! (`policy`), installs the `nash-observe` recording gate (when `NUCLEIC_SHELL_*` +//! config is present, or unconditionally under a `require_observation` policy), +//! registers the exit-time event flush, applies the compat fallback for `-c` input +//! brush cannot parse, and otherwise defers entirely to brush's bash-compatible CLI. use std::os::unix::process::CommandExt; +mod policy; + fn main() { // docs/NASH.md §2: let scripts/tests detect they are running under nash. // SAFETY: called before any other threads exist. unsafe { std::env::set_var("NUCLEIC_NASH", "1") }; - // Kill switch (docs/NASH.md §4.1): behave as the real bash, verbatim argv. - if std::env::var("NUCLEIC_NASH_DISABLE").as_deref() == Ok("1") { - exec_real_bash(); - // exec failed; fall through and run as nash rather than break the command. - } + // Operator levers come from the trusted policy file, never from the environment + // the observed process controls (docs/NASH.md §4.3). + let policy = policy::Policy::load(); - let observing = nash_observe::install_from_env(); + // Observation is installed *before* the levers are acted on, so that a bypass + // attempt and an operator break-glass both leave a row in the feed rather than a + // silence. Under `require_observation` this cannot be turned off from the env. + let observing = + nash_observe::install_from_env(policy.require_observation, &policy.fallback_spool()); if observing { brush_shell::entry::set_exit_hook(Box::new(nash_observe::flush_sync)); + for rejected in &policy.rejected { + nash_observe::report_policy( + &rejected.lever, &rejected.value, "ignored", &rejected.note); + } + if let Some(reason) = &policy.untrusted { + nash_observe::report_policy( + "policy-file", policy::POLICY_PATH, "ignored", + &format!("policy file not trusted ({reason}); operator levers unavailable")); + } + } + + // Kill switch (docs/NASH.md §4.1): behave as the real bash, verbatim argv. Only + // the trusted policy can set this. + if policy.disable { + if observing { + nash_observe::report_policy( + "disable", "1", "honored", + "operator break-glass: re-execing the real bash, unobserved from here"); + nash_observe::flush_sync(); + } + exec_real_bash(&policy); + // exec failed; fall through and run as nash rather than break the command. } // Compat fallback (docs/NASH.md §4.1): if brush can't parse `-c` input @@ -31,7 +58,7 @@ fn main() { if observing { nash_observe::report_fallback(&format!("parse-error: {parse_err}"), &command); } - exec_real_bash(); + exec_real_bash(&policy); // exec failed (no real bash available): let brush surface its own // parse error so the user sees a real diagnostic. } @@ -85,13 +112,12 @@ fn try_parse(command: &str) -> Result<(), String> { /// Replaces this process with the preserved real bash, keeping argv intact. /// Returns only if every candidate exec fails. -fn exec_real_bash() { - let mut candidates: Vec = Vec::new(); - if let Ok(explicit) = std::env::var("NUCLEIC_REAL_BASH") { - candidates.push(explicit); - } - candidates.push("/usr/bin/bash.real".into()); - candidates.push("/bin/bash".into()); +/// +/// The target comes from the trusted policy (or the compiled-in defaults) — never +/// from `NUCLEIC_REAL_BASH`, which would let the observed process choose what nash +/// re-execs into (docs/NASH.md §4.3). +fn exec_real_bash(policy: &policy::Policy) { + let candidates = policy.real_bash_candidates(); let self_path = std::env::current_exe().ok(); let args: Vec = std::env::args().skip(1).collect(); diff --git a/nash/src/policy.rs b/nash/src/policy.rs new file mode 100644 index 0000000..598525e --- /dev/null +++ b/nash/src/policy.rs @@ -0,0 +1,295 @@ +//! Trusted operator policy (docs/NASH.md §4.3). +//! +//! nash's levers — the kill switch, the real-bash target, whether observation is +//! mandatory — are **operator** levers, and the process nash observes must not be +//! able to reach them. The agent owns its own environment (it writes the command +//! line, `export`s at will, and has passwordless `sudo` in the sandbox), so an env +//! var is not a channel an observation layer can trust: `NUCLEIC_NASH_DISABLE=1 +//! ` used to be a complete, silent opt-out of the thing whose entire job +//! is to watch that command. +//! +//! So the levers move here: a **root-owned, non-group/other-writable file** that +//! Nucleic writes (image-baked for narOS, seeded per container otherwise). nash +//! honors levers only from this file, never from the environment. Env levers that +//! *look* like an attempt to steer nash are reported as `policy` events with +//! `action: "ignored"` — a bypass attempt becomes a feed row instead of a silence. +//! +//! This is a bar, not a wall, and the doc is explicit about that: an agent with +//! `sudo` can still edit this file, exec `/usr/bin/bash.real` directly, or run its +//! command under `python3 -c`. What it can no longer do is go dark by accident, or +//! by typing one variable, without leaving a record. + +use std::path::{Path, PathBuf}; + +/// Where the trusted policy lives. Linux (containers, the Linux VM, the runner) is +/// the surface Nucleic controls the image for, so the path is FHS-conventional; the +/// macOS twin covers the macOS VM guest, where an operator writes it with `sudo`. +#[cfg(target_os = "macos")] +pub const POLICY_PATH: &str = "/Library/Application Support/Nucleic/nash.conf"; +#[cfg(not(target_os = "macos"))] +pub const POLICY_PATH: &str = "/etc/nucleic/nash.conf"; + +/// Fallback exec targets when the policy names no `real_bash`, in order. Compiled +/// in rather than read from `NUCLEIC_REAL_BASH` — see the module docs. +pub const DEFAULT_REAL_BASH: &[&str] = &["/usr/bin/bash.real", "/bin/bash"]; + +/// The spool observation falls back to when a `require_observation` policy finds no +/// transport configured (docs/NASH.md §6.2). Matches `ShellSpool.defaultDir`'s Linux +/// twin; the drain op already knows this path. +pub const DEFAULT_SPOOL: &str = "/var/spool/nucleic-nash"; + +/// Env vars nash deliberately no longer honors, with the note each rejection carries. +/// `NUCLEIC_FALLBACK_SHELL` never shipped as a read (docs/NASH.md §4.1 described it); +/// it is listed so that setting it is reported rather than silently inert. +const REJECTED_LEVERS: &[(&str, &str)] = &[ + ( + "NUCLEIC_NASH_DISABLE", + "the kill switch is an operator lever and is read only from the trusted policy file", + ), + ( + "NUCLEIC_REAL_BASH", + "the real-bash target is an operator lever and is read only from the trusted policy file", + ), + ( + "NUCLEIC_FALLBACK_SHELL", + "not a lever nash reads; the fallback target comes from the trusted policy file", + ), +]; + +/// One lever nash saw and did not honor — reported as a `policy` event so the feed +/// shows the attempt. +pub struct Rejected { + pub lever: String, + pub value: String, + pub note: String, +} + +#[derive(Default)] +pub struct Policy { + /// Operator break-glass (docs/NASH.md §4.1): behave as the real bash, verbatim argv. + pub disable: bool, + /// Exec target for the break-glass and the parse-failure fallback. + pub real_bash: Option, + /// Observation may not be turned off by the observed process: `capture=off` is + /// floored to `meta` and a missing transport falls back to the spool. + pub require_observation: bool, + /// Spool dir for the `require_observation` fallback. + pub spool: Option, + /// Env levers seen and ignored. + pub rejected: Vec, + /// Why an existing policy file was not trusted, if it wasn't. + pub untrusted: Option, +} + +impl Policy { + /// Load the trusted policy and scan the environment for rejected levers. Never + /// fails: an absent, unreadable, or untrusted file yields defaults (observe, don't + /// disable), because failing open on *observation* is the §5.5 doctrine — nash must + /// never break a command — while failing open on *levers* is the safe direction here + /// (no policy file means no kill switch, not an agent-steerable one). + pub fn load() -> Self { + Self::load_from(Path::new(POLICY_PATH)) + } + + pub fn load_from(path: &Path) -> Self { + let mut policy = Policy { + rejected: rejected_env_levers(), + ..Default::default() + }; + match read_trusted(path) { + Ok(None) => {} + Ok(Some(text)) => policy.apply(&text), + Err(reason) => policy.untrusted = Some(reason), + } + policy + } + + /// The exec targets for the break-glass / parse-failure fallback, in order. + pub fn real_bash_candidates(&self) -> Vec { + let mut candidates: Vec = Vec::new(); + if let Some(explicit) = &self.real_bash { + candidates.push(explicit.clone()); + } + candidates.extend(DEFAULT_REAL_BASH.iter().map(|s| s.to_string())); + candidates + } + + /// The spool the `require_observation` fallback writes to. + pub fn fallback_spool(&self) -> PathBuf { + self.spool + .clone() + .unwrap_or_else(|| PathBuf::from(DEFAULT_SPOOL)) + } + + fn apply(&mut self, text: &str) { + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "disable" => self.disable = is_true(value), + "require_observation" => self.require_observation = is_true(value), + "real_bash" if !value.is_empty() => self.real_bash = Some(value.to_string()), + "spool" if !value.is_empty() => self.spool = Some(PathBuf::from(value)), + _ => {} + } + } + } +} + +fn is_true(value: &str) -> bool { + matches!(value, "1" | "true" | "yes" | "on") +} + +/// Read `path` if it is trusted. `Ok(None)` means "no policy" (the common case — +/// there is no file); `Err` means a file exists but is not one an operator wrote, +/// which is itself worth reporting. +/// +/// Trusted means: a regular file (not a symlink — a symlink could point the read at +/// something the agent controls), owned by uid 0, and not writable by group or other. +/// The check is on the file rather than the directory because a file the agent +/// created in an agent-writable directory carries the agent's uid and fails here. +fn read_trusted(path: &Path) -> Result, String> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("unreadable: {err}")), + }; + if meta.file_type().is_symlink() { + return Err("is a symlink; a trusted policy must be a regular file".into()); + } + if !meta.is_file() { + return Err("is not a regular file".into()); + } + if meta.uid() != 0 { + return Err(format!("owned by uid {}, not root", meta.uid())); + } + let mode = meta.permissions().mode(); + if mode & 0o022 != 0 { + return Err(format!("mode {:04o} is group/other-writable", mode & 0o7777)); + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|err| format!("unreadable: {err}")) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +/// The env levers present in this process's environment, all of which are ignored. +fn rejected_env_levers() -> Vec { + REJECTED_LEVERS + .iter() + .filter_map(|(lever, note)| { + let value = std::env::var(lever).ok()?; + Some(Rejected { + lever: (*lever).to_string(), + value, + note: (*note).to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn scratch(name: &str) -> PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "nash-policy-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + fn write(path: &Path, contents: &str) { + let mut file = std::fs::File::create(path).unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + } + + #[test] + fn parses_keys_comments_and_whitespace() { + let mut policy = Policy::default(); + policy.apply( + "# a comment\n\n disable = 1 \nrequire_observation=true\n\ + real_bash=/usr/bin/bash.real\nspool=/var/spool/nucleic-nash\n\ + unknown_key=whatever\nnot a pair\n", + ); + assert!(policy.disable); + assert!(policy.require_observation); + assert_eq!(policy.real_bash.as_deref(), Some("/usr/bin/bash.real")); + assert_eq!(policy.spool, Some(PathBuf::from("/var/spool/nucleic-nash"))); + } + + #[test] + fn defaults_are_observe_and_do_not_disable() { + let policy = Policy::default(); + assert!(!policy.disable); + assert!(!policy.require_observation); + assert_eq!(policy.fallback_spool(), PathBuf::from(DEFAULT_SPOOL)); + assert_eq!(policy.real_bash_candidates(), DEFAULT_REAL_BASH.to_vec()); + } + + /// The point of the whole mechanism: a policy file the *agent* could have written is not a + /// policy file. The test process runs as the agent uid, so anything it creates is exactly + /// that case — and the kill switch inside must not take. + #[test] + fn a_file_the_agent_could_have_written_is_not_trusted() { + let path = scratch("nash.conf"); + write(&path, "disable=1\nrequire_observation=1\n"); + let policy = Policy::load_from(&path); + assert!(!policy.disable, "an agent-owned file must not arm the kill switch"); + assert!(!policy.require_observation); + assert!(policy.untrusted.is_some()); + } + + #[test] + fn a_symlink_is_not_trusted() { + let target = scratch("real.conf"); + write(&target, "disable=1\n"); + let link = target.with_file_name("nash.conf"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let policy = Policy::load_from(&link); + assert!(!policy.disable); + assert!(policy.untrusted.unwrap().contains("symlink")); + } + + #[test] + fn a_missing_file_is_not_an_error() { + let policy = Policy::load_from(&scratch("absent.conf")); + assert!(policy.untrusted.is_none()); + assert!(!policy.disable); + } + + /// An explicit `real_bash` leads, but the compiled defaults always remain as fallbacks — + /// a policy naming a path that no longer exists must not strand the break-glass. + #[test] + fn real_bash_candidates_keep_the_defaults() { + let policy = Policy { + real_bash: Some("/opt/bash".into()), + ..Default::default() + }; + assert_eq!(policy.real_bash_candidates()[0], "/opt/bash"); + assert_eq!(&policy.real_bash_candidates()[1..], DEFAULT_REAL_BASH); + } +} diff --git a/nash/tests/observe.rs b/nash/tests/observe.rs index 9be6457..e95978a 100644 --- a/nash/tests/observe.rs +++ b/nash/tests/observe.rs @@ -304,7 +304,9 @@ fn parse_failure_falls_back_to_bash() { .env("NUCLEIC_SHELL_SOCKET", sink.socket()) .env("NUCLEIC_HOOK_TOKEN", "test-token") .env("NUCLEIC_SESSION_ID", "sess-1") - .env("NUCLEIC_REAL_BASH", "/bin/bash") + // No `NUCLEIC_REAL_BASH` here — nash no longer honors it (docs/NASH.md §4.3) and picks + // the target from the trusted policy or its compiled-in defaults, of which `/bin/bash` + // is one. .env_remove("NUCLEIC_SHELL_PARENT") .status() .unwrap(); @@ -750,3 +752,62 @@ fn spool_fallback_when_socket_absent() { assert!(found, "exec event spooled to disk"); let _ = std::fs::remove_dir_all(&dir); } + +/// The bypass this mechanism exists to close (docs/NASH.md §4.3): the agent sets the kill +/// switch in its own environment. nash must run the command *as nash* anyway — the exec event +/// proves it did, since a re-exec'd bash posts nothing — and must report the attempt. +#[test] +fn env_kill_switch_is_ignored_and_reported() { + let sink = Sink::start(); + let status = Command::new(env!("CARGO_BIN_EXE_nash")) + .args(["-c", "echo still-observed"]) + .env("NUCLEIC_SHELL_SOCKET", sink.socket()) + .env("NUCLEIC_HOOK_TOKEN", "test-token") + .env("NUCLEIC_SESSION_ID", "sess-1") + .env("NUCLEIC_NASH_DISABLE", "1") + .env("NUCLEIC_REAL_BASH", "/bin/sh") + .env_remove("NUCLEIC_SHELL_PARENT") + .status() + .unwrap(); + assert_eq!(status.code(), Some(0)); + + let events = sink.events_until(|evs| { + find(evs, "exec", |e| e["argv"][0] == "echo").is_some() + && find(evs, "policy", |e| e["lever"] == "NUCLEIC_REAL_BASH").is_some() + }); + + // Observation survived the attempt. + assert!(find(&events, "exec", |e| e["argv"][0] == "echo").is_some()); + + let disable = find(&events, "policy", |e| e["lever"] == "NUCLEIC_NASH_DISABLE") + .expect("policy event for the kill switch"); + assert_eq!(disable["action"], "ignored"); + assert_eq!(disable["value"], "1"); + + let real_bash = find(&events, "policy", |e| e["lever"] == "NUCLEIC_REAL_BASH") + .expect("policy event for the fallback target"); + assert_eq!(real_bash["action"], "ignored"); +} + +/// The quieter half of the same hole: rather than leaving, the agent blinds nash by telling it +/// not to capture. With no trusted policy nash has no way to know better and stays silent — +/// asserted here so `require_observation`'s baseline is documented rather than assumed. The +/// policy-on side needs a root-owned file, so it is the image smoke test that covers it. +#[test] +fn capture_off_silences_nash_without_a_policy() { + let sink = Sink::start(); + let status = Command::new(env!("CARGO_BIN_EXE_nash")) + .args(["-c", "echo unobserved"]) + .env("NUCLEIC_SHELL_SOCKET", sink.socket()) + .env("NUCLEIC_HOOK_TOKEN", "test-token") + .env("NUCLEIC_SESSION_ID", "sess-1") + .env("NUCLEIC_SHELL_CAPTURE", "off") + .env_remove("NUCLEIC_SHELL_PARENT") + .status() + .unwrap(); + assert_eq!(status.code(), Some(0)); + assert!( + sink.rx.recv_timeout(Duration::from_millis(750)).is_err(), + "capture=off with no policy posts nothing" + ); +}