From 4ef3482cff2ce093575fb2d30d7dde2ad6a91fd0 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 13:05:42 +0300 Subject: [PATCH 1/2] Stop the judge server and model download when aegis exits aegis starts two long-lived children: the L2 judge (llama-server, on the first judged scan) and curl for `install-models` (a ~1.8 GB download). Both outlived aegis on every exit path that skips destructors, reparented to init and still running. Measured on the published v0.1.3 natives (darwin/arm64, linux/arm64, linux/amd64) through the Pilot app: - A flagged scan ends with process::exit(1), which skipped IntentJudge::drop. The judge server stayed up with ppid=1, holding the model and its port, and survived the app's SIGTERM and SIGKILL. - A stop signal (the app's call timeout, a supervisor stop, Ctrl-C, a closed terminal) killed aegis with the default action. install-models left curl downloading with ppid=1; a retry started a second curl on the same file. - status, targets and scan ran `llama-server --version` on every call, even with no model installed: about 0.2 s per call, and once a hang of over 15 s whose llama-server was orphaned when the caller gave up. - The daemon's desktop notifications were never waited for, so it kept one zombie per notification. Changes: - children::spawn records the pid of every child aegis starts. An atexit hook and a SIGTERM/SIGINT/SIGHUP handler SIGTERM the recorded set, then aegis dies of the same signal as before. A signal that is already ignored (nohup, `aegis daemon &`) stays ignored. On Linux each child also gets PR_SET_PDEATHSIG(SIGKILL), which covers SIGKILL of aegis. Stop signals are blocked around fork+record, and unblocked again in the child before exec (macOS's posix_spawn path would otherwise pass the mask on, and the child could not be stopped with SIGTERM). - A judge server another aegis started (found through judge.state) is never recorded, so it keeps running for its owner, as before. - The llama-server check is a PATH lookup instead of an exec. With no model, aegis also skips judge.state and the health probe. - notify() reaps the notifier. tests/child_lifecycle.rs runs the real binary against stub llama-server and curl: a flagged scan, SIGTERM/SIGINT/SIGHUP during install-models, an ignored SIGHUP, SIGKILL on Linux, no llama-server process without a model, and a shared judge left running. On main 4 of 6 fail on macOS and 5 of 7 on Linux (the other two are the stub helper and the shared-judge guard); with this change all pass on darwin/arm64, linux/arm64 and linux/amd64. CI runs them without `|| true`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 3 + src/main.rs | 247 ++++++++++++++++++++++++-- tests/child_lifecycle.rs | 371 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 604 insertions(+), 17 deletions(-) create mode 100644 tests/child_lifecycle.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b2fb38..c575c46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,4 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo build --release + # Child-process lifecycle: nothing aegis starts (judge server, model + # download) may outlive it. Must pass. + - run: cargo test --release --test child_lifecycle - run: cargo test --release || true diff --git a/src/main.rs b/src/main.rs index b5f0996..7dd7d2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,198 @@ use std::{ use std::os::unix::io::RawFd; use unicode_normalization::UnicodeNormalization; +// ───────────────────────────────────────────────────────────────────────────── +// Child processes: nothing aegis starts outlives it +// +// aegis starts two long-lived children: the L2 llama-server (lazily, on the +// first judged scan) and curl for `install-models` (a ~1.8 GB download). Both +// used to outlive aegis on every exit path that skips destructors, reparented +// to init and still running: +// * run_scan's std::process::exit(1) after a flagged file: IntentJudge::drop +// never ran, so the server kept the model loaded and its port bound; +// * SIGTERM / SIGINT / SIGHUP with the default action (no destructors run), +// which is how a caller's timeout or a supervisor stops aegis; +// * SIGKILL, or a crash. +// Every such child goes through `children::spawn`, which records its pid. An +// atexit hook (runs on process::exit and on a normal return from main) and a +// SIGTERM/SIGINT/SIGHUP handler stop the recorded set. On Linux each child also +// carries PR_SET_PDEATHSIG, so the kernel stops it even when aegis is SIGKILLed. +// A judge server another aegis process started (found healthy through +// ~/.aegis/judge.state) is never recorded, so it keeps running for its owner. +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(unix)] +mod children { + use std::io; + use std::process::{Child, Command, ExitStatus}; + use std::sync::atomic::{AtomicI32, Ordering::SeqCst}; + + /// At most this many recorded children at once. aegis has two at most (the + /// judge server and one curl); the spare slots cost nothing. + const SLOTS: usize = 8; + static PIDS: [AtomicI32; SLOTS] = [const { AtomicI32::new(0) }; SLOTS]; + + const STOP_SIGNALS: [libc::c_int; 3] = [libc::SIGTERM, libc::SIGINT, libc::SIGHUP]; + + fn record(pid: u32) { + for slot in PIDS.iter() { + if slot.compare_exchange(0, pid as i32, SeqCst, SeqCst).is_ok() { + return; + } + } + } + + fn forget(pid: u32) { + for slot in PIDS.iter() { + let _ = slot.compare_exchange(pid as i32, 0, SeqCst, SeqCst); + } + } + + /// SIGTERM every recorded child. Only atomics and kill(2), so it is + /// async-signal-safe and the signal handler can call it. + fn stop_all() { + for slot in PIDS.iter() { + let pid = slot.swap(0, SeqCst); + if pid > 0 { + unsafe { libc::kill(pid, libc::SIGTERM); } + } + } + } + + extern "C" fn at_exit() { + stop_all(); + } + + extern "C" fn on_stop_signal(sig: libc::c_int) { + stop_all(); + // Then die of the same signal (it is blocked while this handler runs and + // is delivered on return), so a caller still sees "killed by SIGTERM". + unsafe { + libc::signal(sig, libc::SIG_DFL); + libc::raise(sig); + } + } + + /// Call once, first thing in main. A stop signal that is already ignored + /// (nohup, or `aegis daemon &` from a non-interactive shell) stays ignored. + pub fn install() { + unsafe { + libc::atexit(at_exit); + for sig in STOP_SIGNALS { + let mut old: libc::sigaction = std::mem::zeroed(); + if libc::sigaction(sig, std::ptr::null(), &mut old) == 0 + && old.sa_sigaction == libc::SIG_IGN + { + continue; + } + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = on_stop_signal as extern "C" fn(libc::c_int) as libc::sighandler_t; + libc::sigemptyset(&mut sa.sa_mask); + sa.sa_flags = libc::SA_RESTART; + libc::sigaction(sig, &sa, std::ptr::null_mut()); + } + } + } + + /// Runs `f` with the stop signals blocked, so a signal can't land between + /// fork and `record` and miss the new child. The child would inherit that + /// mask (std's posix_spawn path on macOS keeps it), so `spawn` clears it + /// again in the child before exec. + fn with_stop_signals_blocked(f: impl FnOnce() -> T) -> T { + unsafe { + let mut set: libc::sigset_t = std::mem::zeroed(); + let mut old: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + for sig in STOP_SIGNALS { + libc::sigaddset(&mut set, sig); + } + libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old); + let r = f(); + libc::pthread_sigmask(libc::SIG_SETMASK, &old, std::ptr::null_mut()); + r + } + } + + /// Spawns `cmd` and records the child until `wait`/`forget_reaped`. + /// + /// On Linux the child also gets PR_SET_PDEATHSIG(SIGKILL), so the kernel + /// stops it when aegis dies without running its handlers. The signal is tied + /// to the forking thread, not the process: aegis forks every child from its + /// main thread, which lives exactly as long as the process. + pub fn spawn(cmd: &mut Command) -> io::Result { + use std::os::unix::process::CommandExt; + #[cfg(target_os = "linux")] + let parent = unsafe { libc::getpid() }; + unsafe { + cmd.pre_exec(move || { + // Unblock what with_stop_signals_blocked blocked: a child that + // inherits that mask cannot be stopped with SIGTERM at all. + let mut none: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut none); + libc::pthread_sigmask(libc::SIG_SETMASK, &none, std::ptr::null_mut()); + #[cfg(target_os = "linux")] + { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { + return Err(io::Error::last_os_error()); + } + // aegis died between fork and prctl: that signal will never come. + if libc::getppid() != parent { + return Err(io::Error::from_raw_os_error(libc::ESRCH)); + } + } + Ok(()) + }); + } + with_stop_signals_blocked(|| { + let child = cmd.spawn()?; + record(child.id()); + Ok(child) + }) + } + + /// Waits for a child from `spawn`, then stops tracking it. + pub fn wait(child: &mut Child) -> io::Result { + let r = child.wait(); + forget(child.id()); + r + } + + /// Stops tracking a child from `spawn` that the caller has already reaped. + pub fn forget_reaped(child: &Child) { + forget(child.id()); + } +} + +#[cfg(not(unix))] +mod children { + use std::io; + use std::process::{Child, Command, ExitStatus}; + pub fn install() {} + pub fn spawn(cmd: &mut Command) -> io::Result { cmd.spawn() } + pub fn wait(child: &mut Child) -> io::Result { child.wait() } + pub fn forget_reaped(_child: &Child) {} +} + +/// True if `name` is an executable file in a $PATH directory. Used instead of +/// running `llama-server --version`, which cost a process and 0.2-0.4 s on every +/// status/targets/scan call (and once hung for over 15 s), only to learn that +/// the binary exists. +fn on_path(name: &str) -> bool { + let Some(path) = env::var_os("PATH") else { return false }; + env::split_paths(&path).any(|dir| { + let Ok(meta) = fs::metadata(dir.join(name)) else { return false }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.is_file() && meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + meta.is_file() + } + }) +} + // ───────────────────────────────────────────────────────────────────────────── // IoCs — baked into the binary at compile time // Versioning: bump AEGIS_VERSION when adding patterns. @@ -527,17 +719,21 @@ impl IntentJudge { eprintln!("[AEGIS] Judge (L2): disabled in config — running L1 patterns only"); return Self::disabled(model_path); } - let has_server = std::process::Command::new("llama-server") - .arg("--version").output().is_ok(); - let available = model_path.exists() && has_server; + // A PATH lookup, not an exec: see on_path. + let model_exists = model_path.exists(); + let available = model_exists && on_path("llama-server"); if available { eprintln!("[AEGIS] Judge (L2): {} via llama-server", model_path.display()); - } else if model_path.exists() { + } else if model_exists { eprintln!("[AEGIS] Judge (L2): model found but llama-server not on PATH — L2 disabled"); eprintln!("[AEGIS] Install: brew install llama.cpp (or: brew install pilot-protocol/tap/aegis)"); } else { eprintln!("[AEGIS] Judge (L2): model not found — L2 disabled (run: aegis install-models)"); } + if !available { + // No judge this run: skip the judge.state read/write and health probe. + return Self::disabled(model_path); + } let (port, api_key) = pick_judge_connection(); Self { port, @@ -577,7 +773,7 @@ impl IntentJudge { if http_health_with_key(self.port, &self.api_key) { return true; } if guard.is_none() { - match std::process::Command::new("llama-server") + match children::spawn(std::process::Command::new("llama-server") .args([ "--model", self.model_path.to_str().unwrap_or(""), "--port", &self.port.to_string(), @@ -590,8 +786,7 @@ impl IntentJudge { "--no-webui", ]) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() + .stderr(std::process::Stdio::null())) { Ok(c) => *guard = Some(c), Err(_) => { @@ -609,6 +804,7 @@ impl IntentJudge { while std::time::Instant::now() < deadline { if let Some(child) = guard.as_mut() { if matches!(child.try_wait(), Ok(Some(_))) { + children::forget_reaped(child); *guard = None; self.startup_failed.store(true, Relaxed); eprintln!("[AEGIS] IntentJudge: llama-server exited during startup (port {} in use?) — L2 disabled this run", self.port); @@ -713,7 +909,10 @@ impl Drop for IntentJudge { // Only kill a server WE spawned in this process. If we reused one, the // handle is None and we leave it warm for the next aegis invocation. if let Ok(mut g) = self.server.lock() { - if let Some(child) = g.as_mut() { let _ = child.kill(); } + if let Some(child) = g.as_mut() { + let _ = child.kill(); + let _ = children::wait(child); + } } } } @@ -2362,15 +2561,28 @@ fn poll_targets( /// gap for the background daemon, where stderr isn't visible. fn notify(title: &str, body: &str) { #[cfg(target_os = "macos")] - { + let mut cmd = { let script = format!("display notification {:?} with title {:?}", body, title); - let _ = std::process::Command::new("osascript").args(["-e", &script]) - .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn(); - } + let mut c = std::process::Command::new("osascript"); + c.args(["-e", &script]); + c + }; #[cfg(target_os = "linux")] + let mut cmd = { + let mut c = std::process::Command::new("notify-send"); + c.args(["-u", "critical", title, body]); + c + }; + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Ok(mut child) = cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() { - let _ = std::process::Command::new("notify-send").args(["-u", "critical", title, body]) - .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn(); + // Reap it, or the daemon collects one zombie per notification for as + // long as it runs. The notifier exits on its own within moments. + std::thread::spawn(move || { let _ = child.wait(); }); } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { let _ = (title, body); } @@ -2875,15 +3087,15 @@ fn install_models() { eprintln!("[AEGIS] {} exists but looks incomplete ({} bytes) — re-downloading", filename, size); } eprintln!("[AEGIS] Downloading {} (~{} MB)…", filename, approx_bytes / 1_000_000); - let status = std::process::Command::new("curl") + let status = children::spawn(std::process::Command::new("curl") .args([ "--location", // follow redirects "--fail", // error on HTTP errors "--progress-bar", // show download progress "--output", dest.to_str().unwrap_or(""), url, - ]) - .status(); + ])) + .and_then(|mut c| children::wait(&mut c)); match status { Ok(s) if s.success() => { let size = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); @@ -3297,6 +3509,7 @@ fn cmd_config() { } fn main() { + children::install(); let args: Vec = env::args().collect(); // Commands that don't need the scanner/models loaded. diff --git a/tests/child_lifecycle.rs b/tests/child_lifecycle.rs new file mode 100644 index 0000000..5e6cdf1 --- /dev/null +++ b/tests/child_lifecycle.rs @@ -0,0 +1,371 @@ +//! Nothing aegis starts may outlive it. +//! +//! aegis starts two long-lived children: the L2 judge (`llama-server`, on the +//! first judged scan) and `curl` for `install-models`. These tests run the real +//! binary with HOME and PATH pointed at a scratch directory whose `bin/` holds +//! stub versions of both, and check that each stub is gone once aegis is: +//! after a flagged scan (`exit(1)`), after SIGTERM/SIGINT/SIGHUP, and on Linux +//! after SIGKILL. The stub judge is this test binary itself, re-executed as a +//! tiny HTTP server (see `stub_judge_server`), so the tests need nothing beyond +//! /bin/sh and sleep. + +#![cfg(unix)] + +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +const AEGIS: &str = env!("CARGO_BIN_EXE_aegis"); +const STUB_PORT_ENV: &str = "AEGIS_TEST_STUB_JUDGE_PORT"; + +/// A scratch HOME plus a `bin/` of stub children, removed on drop. Any stub +/// that recorded its pid is SIGKILLed on drop too, so a failing test does not +/// leave a `sleep 600` behind. +struct Sandbox { + root: PathBuf, +} + +impl Sandbox { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!("aegis-lc-{}-{}", name, std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("home/.aegis/models")).unwrap(); + fs::create_dir_all(root.join("bin")).unwrap(); + Sandbox { root } + } + + fn home(&self) -> PathBuf { + self.root.join("home") + } + + fn stub(&self, name: &str, body: &str) { + let p = self.root.join("bin").join(name); + fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap(); + } + + /// `llama-server` stub: records its pid, then becomes the stub HTTP judge + /// on the `--port` aegis passes, answering every judge prompt with ATTACK. + fn stub_judge(&self) { + self.stub( + "llama-server", + &format!( + "echo $$ > \"$HOME/judge.pid\"\n\ + port=\n\ + while [ $# -gt 0 ]; do [ \"$1\" = --port ] && port=$2; shift; done\n\ + {STUB_PORT_ENV}=$port exec \"$AEGIS_TEST_EXE\" --exact stub_judge_server --test-threads=1 -q" + ), + ); + } + + /// `curl` stub: records its pid and sleeps, like a 1.8 GB download. + fn stub_slow_curl(&self) { + self.stub("curl", "echo $$ > \"$HOME/curl.pid\"\nexec sleep 600"); + } + + fn with_model(&self) { + fs::write(self.home().join(".aegis/models/Qwen3-1.7B-Q8_0.gguf"), b"stub model").unwrap(); + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let p = self.root.join(name); + fs::write(&p, text).unwrap(); + p + } + + fn aegis(&self, args: &[&str]) -> Command { + let mut c = Command::new(AEGIS); + c.args(args) + .env_clear() + .env("HOME", self.home()) + .env("PATH", format!("{}:/usr/bin:/bin", self.root.join("bin").display())) + .env("TMPDIR", &self.root) + .env("AEGIS_TEST_EXE", std::env::current_exe().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // Start from default dispositions whatever the test runner inherited + // (a backgrounded shell ignores SIGINT), so each test controls them. + unsafe { + c.pre_exec(|| { + for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP] { + libc::signal(sig, libc::SIG_DFL); + } + Ok(()) + }); + } + c + } + + /// Waits for a stub to write `.pid` and returns the pid. + fn pid(&self, name: &str, within: Duration) -> Option { + let p = self.home().join(format!("{name}.pid")); + let deadline = Instant::now() + within; + loop { + if let Some(pid) = fs::read_to_string(&p).ok().and_then(|s| s.trim().parse().ok()) { + return Some(pid); + } + if Instant::now() > deadline { + return None; + } + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + for name in ["judge", "curl"] { + if let Some(pid) = fs::read_to_string(self.home().join(format!("{name}.pid"))) + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + unsafe { libc::kill(pid, libc::SIGKILL) }; + } + } + let _ = fs::remove_dir_all(&self.root); + } +} + +/// True while `pid` is a live process. A zombie counts as gone: it has exited +/// and only waits for its (new) parent to reap it. +fn alive(pid: i32) -> bool { + if unsafe { libc::kill(pid, 0) } != 0 { + return false; + } + #[cfg(target_os = "linux")] + { + match fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => !matches!(stat.rsplit_once(") ").map(|(_, rest)| rest.as_bytes()[0]), Some(b'Z') | Some(b'X')), + Err(_) => false, + } + } + #[cfg(not(target_os = "linux"))] + { + match Command::new("ps").args(["-o", "stat=", "-p", &pid.to_string()]).output() { + Ok(out) => { + let stat = String::from_utf8_lossy(&out.stdout); + !stat.trim().is_empty() && !stat.trim_start().starts_with('Z') + } + Err(_) => true, + } + } +} + +fn gone_within(pid: i32, within: Duration) -> bool { + let deadline = Instant::now() + within; + while Instant::now() < deadline { + if !alive(pid) { + return true; + } + std::thread::sleep(Duration::from_millis(20)); + } + !alive(pid) +} + +fn wait_within(child: &mut Child, within: Duration) -> ExitStatus { + let deadline = Instant::now() + within; + loop { + if let Some(st) = child.try_wait().unwrap() { + return st; + } + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("aegis (pid {}) still running after {:?}", child.id(), within); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn signal(child: &Child, sig: libc::c_int) { + assert_eq!(unsafe { libc::kill(child.id() as i32, sig) }, 0); +} + +/// Not a test on its own: the `llama-server` stub re-executes this binary with +/// this filter and `AEGIS_TEST_STUB_JUDGE_PORT` set, and it then serves +/// /health and answers every chat completion with ATTACK until it is killed. +/// Without the env var it returns at once. +#[test] +fn stub_judge_server() { + let Ok(port) = std::env::var(STUB_PORT_ENV) else { return }; + let listener = TcpListener::bind(("127.0.0.1", port.parse::().unwrap())).unwrap(); + for conn in listener.incoming() { + let Ok(mut s) = conn else { continue }; + let _ = s.set_read_timeout(Some(Duration::from_secs(5))); + let mut req = Vec::new(); + let mut buf = [0u8; 8192]; + // Read the headers, then Content-Length bytes of body. + let (head_end, want) = loop { + let Ok(n) = s.read(&mut buf) else { break (None, 0) }; + if n == 0 { + break (None, 0); + } + req.extend_from_slice(&buf[..n]); + if let Some(i) = req.windows(4).position(|w| w == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&req[..i]).to_lowercase(); + let len = head + .lines() + .find_map(|l| l.strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap_or(0))) + .unwrap_or(0); + break (Some(i + 4), len); + } + }; + let Some(head_end) = head_end else { continue }; + while req.len() < head_end + want { + match s.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(&buf[..n]), + } + } + let body = if req.starts_with(b"GET") { + r#"{"status":"ok"}"# + } else { + r#"{"choices":[{"message":{"content":"ATTACK"}}]}"# + }; + let _ = write!( + s, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + } +} + +/// LEAK: run_scan ends a flagged scan with process::exit(1), which skipped +/// IntentJudge::drop, so the llama-server aegis had started kept running, +/// reparented to init, with the model loaded and its port bound. +#[test] +fn flagged_scan_stops_the_judge_it_started() { + let sb = Sandbox::new("flagged"); + sb.with_model(); + sb.stub_judge(); + let note = sb.file("note.txt", "Deploy notes for this week.\n"); + + let mut aegis = sb.aegis(&["scan", note.to_str().unwrap()]).spawn().unwrap(); + let st = wait_within(&mut aegis, Duration::from_secs(90)); + + let judge = sb.pid("judge", Duration::from_secs(1)).expect("aegis never started the judge server"); + assert_eq!(st.code(), Some(1), "the stub judge says ATTACK, so the scan is flagged: {st:?}"); + assert!( + gone_within(judge, Duration::from_secs(5)), + "judge server pid {judge} outlived aegis after a flagged scan" + ); +} + +/// LEAK: a stop signal (the adapter's call timeout, a supervisor stop, Ctrl-C, +/// a closed terminal) killed aegis without destructors and left curl +/// downloading the model, reparented to init. +#[test] +fn stop_signals_stop_the_model_download() { + for (name, sig) in [("SIGTERM", libc::SIGTERM), ("SIGINT", libc::SIGINT), ("SIGHUP", libc::SIGHUP)] { + let sb = Sandbox::new(&format!("dl-{name}")); + sb.stub_slow_curl(); + let mut aegis = sb.aegis(&["install-models"]).spawn().unwrap(); + let curl = sb.pid("curl", Duration::from_secs(10)).expect("install-models never started curl"); + + signal(&aegis, sig); + let st = wait_within(&mut aegis, Duration::from_secs(5)); + assert_eq!(st.signal(), Some(sig), "{name}: aegis should still die of {name}: {st:?}"); + assert!(gone_within(curl, Duration::from_secs(5)), "{name}: curl pid {curl} outlived aegis"); + } +} + +/// `nohup aegis daemon` (and `aegis daemon &` from a script) start aegis with +/// SIGHUP/SIGINT ignored. That must stay so: the handler is only installed for +/// signals that are not already ignored. +#[test] +fn ignored_sighup_stays_ignored() { + let sb = Sandbox::new("nohup"); + sb.stub_slow_curl(); + let mut cmd = sb.aegis(&["install-models"]); + unsafe { + cmd.pre_exec(|| { + libc::signal(libc::SIGHUP, libc::SIG_IGN); + Ok(()) + }); + } + let mut aegis = cmd.spawn().unwrap(); + let curl = sb.pid("curl", Duration::from_secs(10)).expect("install-models never started curl"); + + signal(&aegis, libc::SIGHUP); + std::thread::sleep(Duration::from_millis(500)); + assert!(aegis.try_wait().unwrap().is_none(), "aegis died of an ignored SIGHUP"); + assert!(alive(curl), "curl died although aegis ignores SIGHUP"); + + signal(&aegis, libc::SIGTERM); + wait_within(&mut aegis, Duration::from_secs(5)); + assert!(gone_within(curl, Duration::from_secs(5)), "curl pid {curl} outlived aegis"); +} + +/// SIGKILL runs no handler at all; on Linux the kernel's parent-death signal +/// stops the child instead. +#[cfg(target_os = "linux")] +#[test] +fn sigkill_stops_the_model_download_on_linux() { + let sb = Sandbox::new("dl-sigkill"); + sb.stub_slow_curl(); + let mut aegis = sb.aegis(&["install-models"]).spawn().unwrap(); + let curl = sb.pid("curl", Duration::from_secs(10)).expect("install-models never started curl"); + + signal(&aegis, libc::SIGKILL); + wait_within(&mut aegis, Duration::from_secs(5)); + assert!(gone_within(curl, Duration::from_secs(5)), "curl pid {curl} outlived a SIGKILLed aegis"); +} + +/// status/targets/scan used to exec `llama-server --version` on every call even +/// with no model installed (0.2-0.4 s each; once it hung for over 15 s and was +/// orphaned when the caller gave up). With no model, no llama-server runs. +#[test] +fn no_model_means_no_llama_server_process() { + let sb = Sandbox::new("nomodel"); + sb.stub("llama-server", "touch \"$HOME/llama-server.ran\""); + let note = sb.file("note.txt", "hello\n"); + for args in [vec!["status"], vec!["targets"], vec!["scan", note.to_str().unwrap()]] { + let mut aegis = sb.aegis(&args).spawn().unwrap(); + let st = wait_within(&mut aegis, Duration::from_secs(30)); + assert!(st.success(), "aegis {args:?}: {st:?}"); + } + assert!(!sb.home().join("llama-server.ran").exists(), "aegis ran llama-server with no model installed"); +} + +/// A judge server that another aegis process started (recorded in +/// ~/.aegis/judge.state) is shared, not owned: a scan that reuses it must leave +/// it running. +#[test] +fn shared_judge_started_elsewhere_keeps_running() { + let sb = Sandbox::new("shared"); + sb.with_model(); + sb.stub_judge(); + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let mut server = Command::new(sb.root.join("bin/llama-server")) + .args(["--port", &port.to_string()]) + .env("HOME", sb.home()) + .env("AEGIS_TEST_EXE", std::env::current_exe().unwrap()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!(Instant::now() < deadline, "stub judge never listened on {port}"); + std::thread::sleep(Duration::from_millis(20)); + } + fs::write(sb.home().join(".aegis/judge.state"), format!("{port}:{}\n", "ab".repeat(32))).unwrap(); + + let note = sb.file("note.txt", "Deploy notes for this week.\n"); + let mut aegis = sb.aegis(&["scan", note.to_str().unwrap()]).spawn().unwrap(); + let st = wait_within(&mut aegis, Duration::from_secs(90)); + assert_eq!(st.code(), Some(1), "the shared stub judge says ATTACK: {st:?}"); + + std::thread::sleep(Duration::from_millis(300)); + let still_running = server.try_wait().unwrap().is_none(); + let _ = server.kill(); + let _ = server.wait(); + assert!(still_running, "aegis stopped a judge server it did not start"); +} From 8ceabce258316ef951c72ccf8ee1b0737737706b Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 24 Sep 2026 13:05:50 +0300 Subject: [PATCH 2/2] Bump version to 0.1.5 So a v0.1.5 tag on this commit builds natives that report 0.1.5, and the Pilot app (io.pilot.aegis) can ship them as 0.1.5. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c012c79..3cf65d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "aegis" -version = "0.1.4" +version = "0.1.5" dependencies = [ "aho-corasick", "base64", diff --git a/Cargo.toml b/Cargo.toml index 1b08b5d..e88f037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aegis" -version = "0.1.4" +version = "0.1.5" edition = "2021" [[bin]] diff --git a/src/main.rs b/src/main.rs index 7dd7d2c..5cb30bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,7 +216,7 @@ fn on_path(name: &str) -> bool { // Versioning: bump AEGIS_VERSION when adding patterns. // ───────────────────────────────────────────────────────────────────────────── -const AEGIS_VERSION: &str = "0.1.4"; +const AEGIS_VERSION: &str = "0.1.5"; /// Tier 1: high-confidence patterns — scanned everywhere, in all formats. /// These are unambiguous injection signals regardless of context.