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")]
|
#[serde(rename = "dropped", rename_all = "camelCase")]
|
||||||
Dropped { seq: u64, ts: u64, count: u64 },
|
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
|
/// 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 {
|
impl Config {
|
||||||
fn from_env() -> Option<Self> {
|
/// Read transport config from the environment.
|
||||||
if std::env::var("NUCLEIC_SHELL_CAPTURE").as_deref() == Ok("off") {
|
///
|
||||||
|
/// `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;
|
return None;
|
||||||
}
|
}
|
||||||
let socket = std::env::var_os("NUCLEIC_SHELL_SOCKET").map(PathBuf::from);
|
let socket = std::env::var_os("NUCLEIC_SHELL_SOCKET").map(PathBuf::from);
|
||||||
let hook_url = std::env::var("NUCLEIC_SHELL_HOOK_URL")
|
let hook_url = std::env::var("NUCLEIC_SHELL_HOOK_URL")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| parse_hook_url(&u));
|
.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 socket.is_none() && hook_url.is_none() && spool.is_none() {
|
||||||
|
if !require_observation {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
spool = Some(fallback_spool.to_path_buf());
|
||||||
|
}
|
||||||
Some(Self {
|
Some(Self {
|
||||||
socket,
|
socket,
|
||||||
hook_url,
|
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
|
/// configured, installs the recording gate into brush-core and starts the
|
||||||
/// flusher thread. Returns whether observation is active.
|
/// 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
|
/// 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.
|
/// parent and re-exports it as this shell's own id for child shells.
|
||||||
pub fn install_from_env() -> bool {
|
pub fn install_from_env(require_observation: bool, fallback_spool: &Path) -> bool {
|
||||||
let Some(cfg) = Config::from_env() else {
|
let Some(cfg) = Config::from_env(require_observation, fallback_spool) else {
|
||||||
return false;
|
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
|
/// 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`.
|
/// re-exec the real bash because it could not handle `input`.
|
||||||
pub fn report_fallback(reason: &str, input: &str) {
|
pub fn report_fallback(reason: &str, input: &str) {
|
||||||
|
|||||||
+44
-18
@@ -1,26 +1,53 @@
|
|||||||
//! nash — the Nucleic agent shell (see docs/NASH.md).
|
//! nash — the Nucleic agent shell (see docs/NASH.md).
|
||||||
//!
|
//!
|
||||||
//! A thin binary around the vendored brush fork: installs the `nash-observe`
|
//! A thin binary around the vendored brush fork: loads the trusted operator policy
|
||||||
//! recording gate (when `NUCLEIC_SHELL_*` config is present), registers the
|
//! (`policy`), installs the `nash-observe` recording gate (when `NUCLEIC_SHELL_*`
|
||||||
//! exit-time event flush, applies the compat fallback for `-c` input brush
|
//! config is present, or unconditionally under a `require_observation` policy),
|
||||||
//! cannot parse, and otherwise defers entirely to brush's bash-compatible CLI.
|
//! 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;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
mod policy;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// docs/NASH.md §2: let scripts/tests detect they are running under nash.
|
// docs/NASH.md §2: let scripts/tests detect they are running under nash.
|
||||||
// SAFETY: called before any other threads exist.
|
// SAFETY: called before any other threads exist.
|
||||||
unsafe { std::env::set_var("NUCLEIC_NASH", "1") };
|
unsafe { std::env::set_var("NUCLEIC_NASH", "1") };
|
||||||
|
|
||||||
// Kill switch (docs/NASH.md §4.1): behave as the real bash, verbatim argv.
|
// Operator levers come from the trusted policy file, never from the environment
|
||||||
if std::env::var("NUCLEIC_NASH_DISABLE").as_deref() == Ok("1") {
|
// the observed process controls (docs/NASH.md §4.3).
|
||||||
exec_real_bash();
|
let policy = policy::Policy::load();
|
||||||
// exec failed; fall through and run as nash rather than break the command.
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if observing {
|
||||||
brush_shell::entry::set_exit_hook(Box::new(nash_observe::flush_sync));
|
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
|
// Compat fallback (docs/NASH.md §4.1): if brush can't parse `-c` input
|
||||||
@@ -31,7 +58,7 @@ fn main() {
|
|||||||
if observing {
|
if observing {
|
||||||
nash_observe::report_fallback(&format!("parse-error: {parse_err}"), &command);
|
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
|
// exec failed (no real bash available): let brush surface its own
|
||||||
// parse error so the user sees a real diagnostic.
|
// 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.
|
/// Replaces this process with the preserved real bash, keeping argv intact.
|
||||||
/// Returns only if every candidate exec fails.
|
/// Returns only if every candidate exec fails.
|
||||||
fn exec_real_bash() {
|
///
|
||||||
let mut candidates: Vec<String> = Vec::new();
|
/// The target comes from the trusted policy (or the compiled-in defaults) — never
|
||||||
if let Ok(explicit) = std::env::var("NUCLEIC_REAL_BASH") {
|
/// from `NUCLEIC_REAL_BASH`, which would let the observed process choose what nash
|
||||||
candidates.push(explicit);
|
/// re-execs into (docs/NASH.md §4.3).
|
||||||
}
|
fn exec_real_bash(policy: &policy::Policy) {
|
||||||
candidates.push("/usr/bin/bash.real".into());
|
let candidates = policy.real_bash_candidates();
|
||||||
candidates.push("/bin/bash".into());
|
|
||||||
|
|
||||||
let self_path = std::env::current_exe().ok();
|
let self_path = std::env::current_exe().ok();
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
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_SHELL_SOCKET", sink.socket())
|
||||||
.env("NUCLEIC_HOOK_TOKEN", "test-token")
|
.env("NUCLEIC_HOOK_TOKEN", "test-token")
|
||||||
.env("NUCLEIC_SESSION_ID", "sess-1")
|
.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")
|
.env_remove("NUCLEIC_SHELL_PARENT")
|
||||||
.status()
|
.status()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -750,3 +752,62 @@ fn spool_fallback_when_socket_absent() {
|
|||||||
assert!(found, "exec event spooled to disk");
|
assert!(found, "exec event spooled to disk");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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