Merge nucleic/rustic-meadow-koala-wtwq into dev

This commit is contained in:
2026-07-27 02:26:30 -07:00
parent 1d6b240dbb
commit 195ac00d0b
4 changed files with 457 additions and 25 deletions
+56 -6
View File
@@ -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<HookUrl> {
}
impl Config {
fn from_env() -> Option<Self> {
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<Self> {
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<String>, 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) {
+44 -18
View File
@@ -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<String> = 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<String> = std::env::args().skip(1).collect();
+295
View File
@@ -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
//! <anything>` 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<String>,
/// 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<PathBuf>,
/// Env levers seen and ignored.
pub rejected: Vec<Rejected>,
/// Why an existing policy file was not trusted, if it wasn't.
pub untrusted: Option<String>,
}
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<String> {
let mut candidates: Vec<String> = 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<Option<String>, 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> {
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);
}
}
+62 -1
View File
@@ -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"
);
}