Merge nucleic/rustic-meadow-koala-wtwq into dev
This commit is contained in:
+55
-5
@@ -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,18 +195,32 @@ 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() {
|
||||
if !require_observation {
|
||||
return None;
|
||||
}
|
||||
spool = Some(fallback_spool.to_path_buf());
|
||||
}
|
||||
Some(Self {
|
||||
socket,
|
||||
hook_url,
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user