diff --git a/Cargo.lock b/Cargo.lock index f4841b71..b62dbe26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,7 +130,7 @@ dependencies = [ "tempfile", "thiserror", "ureq", - "which 6.0.3", + "which", ] [[package]] @@ -151,7 +151,7 @@ dependencies = [ "thiserror", "tokio", "tokio-util", - "toml 0.8.23", + "toml", ] [[package]] @@ -276,7 +276,6 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", - "form_urlencoded", "futures-util", "http", "http-body", @@ -290,15 +289,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -317,7 +312,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -347,29 +341,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn", - "which 4.4.2", -] - [[package]] name = "bitflags" version = "2.13.0" @@ -440,16 +411,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", + "shlex", ] [[package]] @@ -499,17 +461,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" version = "4.6.1" @@ -858,32 +809,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flared" -version = "0.1.0" -dependencies = [ - "agentflare-artifacts", - "axum", - "chrono", - "clap", - "dirs", - "eyre", - "http-body-util", - "kill_tree", - "pretty_assertions", - "regex", - "serde", - "serde_json", - "sysinfo 0.38.4", - "tempfile", - "tokio", - "tokio-stream", - "toml 0.9.12+spec-1.1.0", - "tower", - "tracing", - "tracing-subscriber", -] - [[package]] name = "flate2" version = "1.1.9" @@ -1060,12 +985,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "hashbrown" version = "0.16.1" @@ -1389,15 +1308,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -1425,46 +1335,18 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kill_tree" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3879339076ac4da142cc852d91693462927cbc99773b5ea422e4834e68c4ff2" -dependencies = [ - "bindgen", - "nix 0.27.1", - "tracing", - "windows 0.52.0", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "libredox" version = "0.1.18" @@ -1530,15 +1412,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "matchit" version = "0.8.4" @@ -1557,12 +1430,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1584,17 +1451,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "nix" version = "0.31.3" @@ -1607,16 +1463,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "ntapi" version = "0.4.3" @@ -1626,15 +1472,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-conv" version = "0.2.2" @@ -1811,16 +1648,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1838,7 +1665,7 @@ checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" dependencies = [ "futures", "indexmap", - "nix 0.31.3", + "nix", "tokio", "tracing", "windows 0.62.2", @@ -2118,12 +1945,6 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustix" version = "0.38.44" @@ -2277,17 +2098,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -2297,27 +2107,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2362,12 +2151,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -2659,26 +2442,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit", ] -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -2688,15 +2456,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -2705,19 +2464,10 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", + "winnow", ] [[package]] @@ -2726,12 +2476,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tower" version = "0.5.3" @@ -2745,7 +2489,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2784,7 +2527,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2821,33 +2563,15 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - [[package]] name = "tracing-subscriber" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", "sharded-slab", - "smallvec", "thread_local", - "tracing", "tracing-core", - "tracing-log", ] [[package]] @@ -3081,18 +2805,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "which" version = "6.0.3" @@ -3127,16 +2839,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets", -] - [[package]] name = "windows" version = "0.57.0" @@ -3168,15 +2870,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-core" version = "0.57.0" @@ -3409,12 +3102,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" - [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index 2a3dd9c3..07655b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/ponytail", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/caveman", "crates/agentflare-artifacts", "crates/flared"] +members = ["crates/ponytail", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/caveman", "crates/agentflare-artifacts"] resolver = "2" [package] diff --git a/crates/flared/Cargo.toml b/crates/flared/Cargo.toml deleted file mode 100644 index a037425a..00000000 --- a/crates/flared/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "flared" -version = "0.1.0" -edition = "2024" -rust-version = "1.91" -description = "Always-on cross-platform supervisor for AI-agent workload hygiene." -license = "MIT" -repository = "https://github.com/getappz/agentflare" - -[[bin]] -name = "flared" -path = "src/main.rs" - -[dependencies] -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.9" -dirs = "6" -chrono = { version = "0.4", features = ["serde"] } -eyre = "0.6" -regex = "1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -sysinfo = { version = "0.38", default-features = false, features = ["system"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "signal"] } -tokio-stream = "0.1" -axum = "0.8" -kill_tree = { version = "0.2", features = ["blocking"] } -agentflare-artifacts = { version = "0.1.0", path = "../agentflare-artifacts" } - -[dev-dependencies] -tempfile = "3" -pretty_assertions = "1" -tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" diff --git a/crates/flared/src/actions.rs b/crates/flared/src/actions.rs deleted file mode 100644 index 812c22e0..00000000 --- a/crates/flared/src/actions.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Gated executor. The last line of defense: even a Safe-planned action is -//! re-verified against a fresh process snapshot immediately before the kill, -//! closing the window between planning and execution (TOCTOU). - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::model::{identity_matches, Action, ActionKind, Lease, ProcInfo, Risk}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ExecutionOutcome { - pub action: Action, - pub executed: bool, - pub detail: String, -} - -/// Run planned actions through the gate. -/// -/// - `execute == false`: pure dry run, nothing is ever killed. -/// - `execute == true`: only `KillTree` actions with risk `Safe` run, and only -/// after the lease identity is re-verified against `fresh_procs`. -/// -/// `kill` performs the actual process-tree termination (injected for tests). -pub fn execute_actions( - actions: &[Action], - leases: &[Lease], - fresh_procs: &HashMap, - tolerance_secs: u64, - execute: bool, - kill: &mut dyn FnMut(u32) -> eyre::Result<()>, -) -> Vec { - let mut outcomes = Vec::new(); - for action in actions { - let mut executed = false; - let detail = if !execute { - "dry run".to_string() - } else if action.kind != ActionKind::KillTree || action.risk != Risk::Safe { - "gated: only Safe kill-tree actions are executable".to_string() - } else { - let lease_id = action.target.strip_prefix("lease:").unwrap_or(""); - let lease = leases.iter().find(|l| l.id == lease_id); - match (lease, action.pid) { - (Some(lease), Some(pid)) => match fresh_procs.get(&pid) { - Some(live) - if identity_matches( - &lease.identity, - &live.name, - live.start_time, - tolerance_secs, - ) => - { - match kill(pid) { - Ok(()) => { - executed = true; - format!("killed process tree rooted at pid {pid}") - } - Err(err) => format!("kill failed: {err}"), - } - } - Some(live) => format!( - "refused: re-verification failed, pid {pid} is now '{}'", - live.name - ), - None => format!("refused: re-verification failed, pid {pid} already exited"), - }, - _ => "refused: re-verification failed, lease no longer exists".to_string(), - } - }; - outcomes.push(ExecutionOutcome { action: action.clone(), executed, detail }); - } - outcomes -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{ActionKind, Bucket, Identity, Risk}; - use pretty_assertions::assert_eq; - - fn proc(pid: u32, name: &str, start: u64) -> ProcInfo { - ProcInfo { - pid, - ppid: Some(1), - name: name.into(), - cmd: name.into(), - start_time: start, - cpu_pct: 0.0, - rss_bytes: 0, - bucket: Bucket::Agents, - protected: false, - } - } - - fn lease(id: &str, pid: u32, name: &str, start: u64) -> Lease { - Lease { - id: id.into(), - pid, - class: "agent".into(), - created_at: 0, - ttl_seconds: 10, - identity: Identity { exe_name: name.into(), start_time: start }, - allow_kill: true, - } - } - - fn action(kind: ActionKind, target: &str, pid: u32, risk: Risk) -> Action { - Action { kind, target: target.into(), reason: "test".into(), pid: Some(pid), risk } - } - - fn run( - actions: &[Action], - leases: &[Lease], - procs: HashMap, - execute: bool, - ) -> (Vec, Vec) { - let mut killed = Vec::new(); - let outcomes = execute_actions(actions, leases, &procs, 2, execute, &mut |pid| { - killed.push(pid); - Ok(()) - }); - (outcomes, killed) - } - - #[test] - fn dry_run_never_kills() { - let leases = [lease("l1", 10, "agent.exe", 700)]; - let procs = [(10, proc(10, "agent.exe", 700))].into_iter().collect(); - let actions = [action(ActionKind::KillTree, "lease:l1", 10, Risk::Safe)]; - let (outcomes, killed) = run(&actions, &leases, procs, false); - assert_eq!(killed, Vec::::new()); - assert!(!outcomes[0].executed); - } - - #[test] - fn safe_verified_action_kills_on_execute() { - let leases = [lease("l1", 10, "agent.exe", 700)]; - let procs = [(10, proc(10, "agent.exe", 700))].into_iter().collect(); - let actions = [action(ActionKind::KillTree, "lease:l1", 10, Risk::Safe)]; - let (outcomes, killed) = run(&actions, &leases, procs, true); - assert_eq!(killed, vec![10]); - assert!(outcomes[0].executed); - } - - #[test] - fn blocked_and_review_actions_never_execute_even_with_execute_flag() { - let leases = [lease("l1", 10, "agent.exe", 700)]; - let procs = [(10, proc(10, "agent.exe", 700))].into_iter().collect(); - let actions = [ - action(ActionKind::Review, "lease:l1", 10, Risk::Blocked), - action(ActionKind::Review, "lease:l1", 10, Risk::ReviewOnly), - ]; - let (outcomes, killed) = run(&actions, &leases, procs, true); - assert_eq!(killed, Vec::::new()); - assert!(outcomes.iter().all(|o| !o.executed)); - } - - #[test] - fn kill_refused_when_fresh_snapshot_no_longer_matches() { - // Planned Safe, but by execution time the pid belongs to another exe. - let leases = [lease("l1", 10, "agent.exe", 700)]; - let procs = [(10, proc(10, "imposter.exe", 700))].into_iter().collect(); - let actions = [action(ActionKind::KillTree, "lease:l1", 10, Risk::Safe)]; - let (outcomes, killed) = run(&actions, &leases, procs, true); - assert_eq!(killed, Vec::::new()); - assert!(!outcomes[0].executed); - assert!(outcomes[0].detail.contains("re-verif"), "detail: {}", outcomes[0].detail); - } - - #[test] - fn kill_refused_when_process_exited_before_execution() { - let leases = [lease("l1", 10, "agent.exe", 700)]; - let actions = [action(ActionKind::KillTree, "lease:l1", 10, Risk::Safe)]; - let (outcomes, killed) = run(&actions, &leases, HashMap::new(), true); - assert_eq!(killed, Vec::::new()); - assert!(!outcomes[0].executed); - } - - #[test] - fn kill_refused_when_lease_vanished() { - // Safe action references a lease that no longer exists. - let procs = [(10, proc(10, "agent.exe", 700))].into_iter().collect(); - let actions = [action(ActionKind::KillTree, "lease:gone", 10, Risk::Safe)]; - let (outcomes, killed) = run(&actions, &[], procs, true); - assert_eq!(killed, Vec::::new()); - assert!(!outcomes[0].executed); - } -} diff --git a/crates/flared/src/artifacts.rs b/crates/flared/src/artifacts.rs deleted file mode 100644 index 23cb64cb..00000000 --- a/crates/flared/src/artifacts.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Artifact routes: serve the shared agentflare artifact store under -//! `/artifacts` on flared's HTTP port, so artifact URLs survive individual -//! agent sessions and other agents can fetch handoffs with no session open. -//! -//! Read-only by design: publishing stays with the MCP tools, which write -//! to the store directory directly. GET routes bypass the token guard. - -use std::convert::Infallible; -use std::sync::Arc; -use std::time::Duration; - -use agentflare_artifacts::{render_artifact_page, render_index, valid_id, ArtifactStore}; -use axum::extract::{Path as AxumPath, State}; -use axum::http::StatusCode; -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::response::Html; -use axum::routing::get; -use axum::{Json, Router}; -use tokio_stream::wrappers::IntervalStream; -use tokio_stream::{Stream, StreamExt}; - -/// URL prefix the artifact routes are mounted under; baked into rendered -/// pages' internal links. -pub const ROUTE_PREFIX: &str = "/artifacts"; - -pub fn router(store: Arc) -> Router { - Router::new() - .route("/artifacts", get(index)) - .route("/artifacts/", get(index)) - .route("/artifacts/{id}", get(latest_page)) - .route("/artifacts/{id}/v/{version}", get(version_page)) - .route("/artifacts/{id}/versions", get(versions)) - .route("/artifacts/{id}/live", get(live)) - .with_state(store) -} - -async fn index(State(store): State>) -> Html { - Html(render_index(&store, ROUTE_PREFIX)) -} - -async fn latest_page( - State(store): State>, - AxumPath(id): AxumPath, -) -> Result, StatusCode> { - page(&store, &id, None) -} - -async fn version_page( - State(store): State>, - AxumPath((id, version)): AxumPath<(String, u32)>, -) -> Result, StatusCode> { - page(&store, &id, Some(version)) -} - -fn page(store: &ArtifactStore, id: &str, version: Option) -> Result, StatusCode> { - if !valid_id(id) { - return Err(StatusCode::NOT_FOUND); - } - let artifact = match version { - Some(v) => store.get_version(id, v), - None => store.get(id), - } - .map_err(|_| StatusCode::NOT_FOUND)?; - Ok(Html(render_artifact_page(&artifact, version.is_none(), ROUTE_PREFIX))) -} - -async fn versions( - State(store): State>, - AxumPath(id): AxumPath, -) -> Result, StatusCode> { - if !valid_id(&id) { - return Err(StatusCode::NOT_FOUND); - } - let history = store.versions(&id).map_err(|_| StatusCode::NOT_FOUND)?; - Ok(Json(serde_json::to_value(history).unwrap_or_default())) -} - -/// Live-reload stream. This process never publishes (the MCP tools do, -/// from their own processes), so there is no broadcast channel to listen -/// on — poll the disk store and emit an event when the artifact changes. -async fn live( - State(store): State>, - AxumPath(id): AxumPath, -) -> Result>>, StatusCode> { - if !valid_id(&id) { - return Err(StatusCode::NOT_FOUND); - } - let mut last = store.get(&id).ok().map(|a| (a.version, a.updated_at)); - let ticks = IntervalStream::new(tokio::time::interval(Duration::from_secs(2))); - let stream = ticks.filter_map(move |_| { - let now = store.get(&id).ok().map(|a| (a.version, a.updated_at)); - if now.is_some() && now != last { - last = now; - Some(Ok(Event::default().event("update").data("update"))) - } else { - None - } - }); - Ok(Sse::new(stream).keep_alive(KeepAlive::default())) -} - -#[cfg(test)] -mod tests { - use super::*; - use agentflare_artifacts::PublishRequest; - use http_body_util::BodyExt; - use tower::ServiceExt; - - fn served() -> (tempfile::TempDir, Arc, Router) { - let dir = tempfile::tempdir().unwrap(); - let store = Arc::new(ArtifactStore::new(dir.path().to_path_buf())); - let router = router(store.clone()); - (dir, store, router) - } - - async fn get_page(app: Router, uri: &str) -> (StatusCode, String) { - let response = app - .oneshot( - axum::http::Request::builder() - .uri(uri) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - (status, String::from_utf8_lossy(&bytes).to_string()) - } - - fn publish(store: &ArtifactStore, name: &str, content: &str) -> String { - store - .publish(&PublishRequest { - name: name.into(), - content: content.into(), - session_id: "s".into(), - ..Default::default() - }) - .unwrap() - .id - } - - #[tokio::test] - async fn index_lists_artifacts_with_prefixed_links() { - let (_dir, store, app) = served(); - let id = publish(&store, "my-doc", "hello"); - let (status, body) = get_page(app, "/artifacts").await; - assert_eq!(status, StatusCode::OK); - assert!(body.contains("agentflare artifacts"), "{body}"); - assert!(body.contains("my-doc"), "{body}"); - assert!(body.contains(&format!("href=\"/artifacts/{id}\"")), "{body}"); - } - - #[tokio::test] - async fn page_serves_content_and_prefixed_live_reload() { - let (_dir, store, app) = served(); - let id = publish(&store, "doc", "PAGE-CONTENT"); - let (status, body) = get_page(app, &format!("/artifacts/{id}")).await; - assert_eq!(status, StatusCode::OK); - assert!(body.contains("PAGE-CONTENT"), "{body}"); - assert!(body.contains(&format!("/artifacts/{id}/live")), "{body}"); - } - - #[tokio::test] - async fn versions_and_snapshots_serve() { - let (_dir, store, app) = served(); - let id = publish(&store, "doc", "OLD-CONTENT"); - store - .publish(&PublishRequest { - name: "doc".into(), - content: "NEW-CONTENT".into(), - session_id: "s".into(), - update_id: Some(id.clone()), - ..Default::default() - }) - .unwrap(); - let (status, body) = get_page(app.clone(), &format!("/artifacts/{id}/v/1")).await; - assert_eq!(status, StatusCode::OK); - assert!(body.contains("OLD-CONTENT"), "{body}"); - assert!(body.contains(&format!("href=\"/artifacts/{id}\"")), "snapshot banner links back under the prefix: {body}"); - let (status, body) = get_page(app, &format!("/artifacts/{id}/versions")).await; - assert_eq!(status, StatusCode::OK); - assert!(body.contains("\"version\""), "{body}"); - } - - #[tokio::test] - async fn unknown_and_invalid_ids_404() { - let (_dir, _store, app) = served(); - let (status, _) = get_page(app.clone(), "/artifacts/nope").await; - assert_eq!(status, StatusCode::NOT_FOUND); - // URL-encoded traversal decodes to a multi-segment id — rejected - let (status, _) = get_page(app, "/artifacts/..%2F..%2Fescape").await; - assert_eq!(status, StatusCode::NOT_FOUND); - } -} diff --git a/crates/flared/src/config.rs b/crates/flared/src/config.rs deleted file mode 100644 index 478a3829..00000000 --- a/crates/flared/src/config.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Configuration: ~/.config/flared/config.toml, every field optional with -//! safe defaults. A missing or unreadable config never stops the daemon. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::policy::OrphanRule; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryCheckConfig { - /// Registry format. Only "lean-ctx" is understood today. - pub kind: String, - pub path: PathBuf, - /// Substring the live process name must contain to count as the - /// registered process (case-insensitive). - pub expected_exe: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct Config { - pub port: u16, - pub light_interval_secs: u64, - pub deep_interval_secs: u64, - pub identity_tolerance_secs: u64, - /// Extra never-touch patterns on top of the protected buckets. - pub protect_patterns: Vec, - pub agent_patterns: Vec, - pub browser_patterns: Vec, - pub terminal_patterns: Vec, - pub desktop_patterns: Vec, - pub build_patterns: Vec, - pub orphan_rules: Vec, - pub registries: Vec, - /// Serve the shared agentflare artifact store under /artifacts on - /// flared's HTTP port so artifact URLs survive individual agent sessions. - pub artifacts_enabled: bool, - /// Store directory shared with the MCP server's artifact tools. - pub artifacts_dir: PathBuf, -} - -impl Default for Config { - fn default() -> Self { - fn strings(items: &[&str]) -> Vec { - items.iter().map(|s| s.to_string()).collect() - } - let lean_ctx_registry = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".local") - .join("share") - .join("lean-ctx") - .join("agents") - .join("registry.json"); - Config { - port: 35273, - light_interval_secs: 60, - deep_interval_secs: 300, - identity_tolerance_secs: 5, - protect_patterns: Vec::new(), - agent_patterns: strings(&[ - "claude", "codex", "aider", "cursor", "copilot", "gemini", "goose", "mcp", - "lean-ctx", "agentflare", "native-host", "opencode", - ]), - browser_patterns: strings(&[ - "chrome", "msedge", "firefox", "brave", "opera", "vivaldi", "safari", - ]), - terminal_patterns: strings(&[ - "windowsterminal", "conhost", "cmd.exe", "powershell", "pwsh", "bash", "zsh", - "fish", "wezterm", "alacritty", "kitty", "iterm", "tmux", - ]), - desktop_patterns: strings(&[ - "explorer.exe", "dwm.exe", "finder", "dock", "gnome-shell", "kwin", "plasmashell", - ]), - build_patterns: strings(&[ - "cargo", "rustc", "sccache", "msbuild", "cl.exe", "link.exe", "gcc", "clang", - "tsc", "vite", "webpack", "gradle", "javac", "go.exe", - ]), - orphan_rules: vec![OrphanRule { - name_pattern: "(?i)mcp|native-host".into(), - require_dead_parent: true, - min_age_secs: 3600, - }], - registries: vec![RegistryCheckConfig { - kind: "lean-ctx".into(), - path: lean_ctx_registry, - expected_exe: "lean-ctx".into(), - }], - artifacts_enabled: true, - artifacts_dir: dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".agentflare") - .join("artifacts"), - } - } -} - -impl Config { - /// Load from `path` (or the default location when None). Missing file or - /// parse error -> defaults, with a warning for the latter. - pub fn load(path: Option<&Path>) -> Config { - let path = path.map(Path::to_path_buf).unwrap_or_else(default_config_path); - let text = match std::fs::read_to_string(&path) { - Ok(text) => text, - Err(_) => return Config::default(), - }; - match toml::from_str(&text) { - Ok(cfg) => cfg, - Err(err) => { - tracing::warn!(path = %path.display(), %err, "unparseable config, using defaults"); - Config::default() - } - } - } -} - -pub fn default_config_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("flared") - .join("config.toml") -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn defaults_are_sane() { - let cfg = Config::default(); - assert_eq!(cfg.port, 35273); - assert_eq!(cfg.light_interval_secs, 60); - assert_eq!(cfg.deep_interval_secs, 300); - assert!(cfg.identity_tolerance_secs >= 2); - assert!(!cfg.agent_patterns.is_empty()); - assert!(!cfg.browser_patterns.is_empty()); - assert!(!cfg.terminal_patterns.is_empty()); - // lean-ctx registry check ships as a default janitor target. - assert_eq!(cfg.registries.len(), 1); - assert_eq!(cfg.registries[0].kind, "lean-ctx"); - assert_eq!(cfg.registries[0].expected_exe, "lean-ctx"); - // artifact serving ships on by default: durable URLs under /artifacts - assert!(cfg.artifacts_enabled); - assert!(cfg.artifacts_dir.ends_with(".agentflare/artifacts")); - } - - #[test] - fn partial_toml_overrides_keep_other_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "port = 4444\n").unwrap(); - let cfg = Config::load(Some(&path)); - assert_eq!(cfg.port, 4444); - assert_eq!(cfg.light_interval_secs, 60); - assert!(!cfg.agent_patterns.is_empty()); - } - - #[test] - fn missing_file_yields_defaults() { - let cfg = Config::load(Some(Path::new("Z:/definitely/not/here.toml"))); - assert_eq!(cfg.port, 35273); - } - - #[test] - fn unparseable_file_yields_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "port = {{{{").unwrap(); - let cfg = Config::load(Some(&path)); - assert_eq!(cfg.port, 35273); - } -} diff --git a/crates/flared/src/daemon.rs b/crates/flared/src/daemon.rs deleted file mode 100644 index 29ca086c..00000000 --- a/crates/flared/src/daemon.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! The sleeping sweep loop and the one-shot sweep it (and the CLI/API) run. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; - -use crate::actions::{execute_actions, ExecutionOutcome}; -use crate::config::Config; -use crate::events::EventLog; -use crate::janitor::lean_ctx::{check_registry, RegistryReport}; -use crate::leases::LeaseStore; -use crate::model::{Action, Finding, ProcInfo}; -use crate::policy::{detect_orphans, plan_lease_actions}; -use crate::scanner::{scan, Pressure}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegistryOutcome { - pub kind: String, - pub path: String, - pub report: RegistryReport, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SweepOutcome { - pub ts: u64, - pub pressure: Pressure, - pub bucket_counts: HashMap, - pub actions: Vec, - pub outcomes: Vec, - pub orphans: Vec, - pub registries: Vec, - pub lease_count: usize, -} - -pub fn unix_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Kill a whole process tree, cross-platform. -pub fn kill_process_tree(pid: u32) -> eyre::Result<()> { - kill_tree::blocking::kill_tree(pid) - .map(|_| ()) - .map_err(|err| eyre::eyre!("kill_tree({pid}): {err}")) -} - -/// The production killer: reloads the lease store and re-queries the OS at -/// the moment of execution, so a lease heartbeated/removed after planning, or -/// a pid recycled after the scan, is never killed. Lease-backed kills require -/// an EXACT start-time match — fuzzy tolerance is for report-only heuristics. -pub fn make_verified_killer(store: &LeaseStore) -> impl FnMut(u32) -> eyre::Result<()> + '_ { - move |pid| { - let now = unix_now(); - let leases = store.load()?; - let lease = leases - .iter() - .find(|l| l.pid == pid && l.allow_kill && l.expires_at() <= now) - .ok_or_else(|| { - eyre::eyre!("no expired kill-authorized lease for pid {pid} at execution time") - })?; - let (name, start) = crate::scanner::identity_of(pid) - .ok_or_else(|| eyre::eyre!("pid {pid} exited before execution"))?; - if !crate::model::identity_matches(&lease.identity, &name, start, 0) { - eyre::bail!("identity changed for pid {pid} at execution time (now '{name}')"); - } - kill_process_tree(pid) - } -} - -/// One full sweep: -/// -/// 1. audit + classify processes -/// 2. drop leases whose pid is gone entirely -/// 3. plan actions for expired leases, run them through the gate -/// (`execute` decides whether Safe kills actually happen) -/// 4. deep sweeps additionally run orphan detection and registry checks -/// (both report-only here) -/// -/// Every sweep appends a summary event to the ledger. -pub fn sweep_once( - cfg: &Config, - store: &LeaseStore, - log: &EventLog, - execute: bool, - deep: bool, - kill: &mut dyn FnMut(u32) -> eyre::Result<()>, -) -> eyre::Result { - let (procs, pressure) = scan(cfg); - let now = unix_now(); - - let mut leases = store.load()?; - let before = leases.len(); - leases.retain(|l| procs.contains_key(&l.pid)); - if leases.len() != before { - store.save(&leases)?; - } - - let expired: Vec<_> = leases.iter().filter(|l| l.expires_at() <= now).cloned().collect(); - // Lease-backed kills demand an exact start-time match; the configured - // tolerance applies only to report-only registry heuristics. - let actions = plan_lease_actions(&expired, &procs, 0); - let outcomes = execute_actions(&actions, &leases, &procs, 0, execute, kill); - for outcome in outcomes.iter().filter(|o| o.executed) { - if let Some(id) = outcome.action.target.strip_prefix("lease:") { - let _ = store.remove(id); - } - } - - let orphans = - if deep { detect_orphans(&procs, &cfg.orphan_rules, now) } else { Vec::new() }; - - let mut registries = Vec::new(); - if deep { - for reg in &cfg.registries { - if reg.kind != "lean-ctx" || !reg.path.exists() { - continue; - } - match check_registry(®.path, &procs, ®.expected_exe, cfg.identity_tolerance_secs) - { - Ok(report) => registries.push(RegistryOutcome { - kind: reg.kind.clone(), - path: reg.path.display().to_string(), - report, - }), - Err(err) => { - tracing::warn!(path = %reg.path.display(), %err, "registry check failed") - } - } - } - } - - let mut bucket_counts: HashMap = HashMap::new(); - for p in procs.values() { - *bucket_counts.entry(format!("{:?}", p.bucket).to_ascii_lowercase()).or_default() += 1; - } - let lease_count = store.load()?.len(); - - let outcome = SweepOutcome { - ts: now, - pressure, - bucket_counts, - actions, - outcomes, - orphans, - registries, - lease_count, - }; - log.append( - "sweep", - serde_json::json!({ - "deep": deep, - "execute": execute, - "pressure": outcome.pressure.level, - "planned": outcome.actions.len(), - "executed": outcome.outcomes.iter().filter(|o| o.executed).count(), - "orphans": outcome.orphans.len(), - "leases": outcome.lease_count, - }), - )?; - Ok(outcome) -} - -/// Snapshot shared between the sweep loop and the HTTP server. -#[derive(Debug, Clone, Default, Serialize)] -pub struct Snapshot { - pub last: Option, - pub processes: Vec, -} - -pub type SharedSnapshot = Arc>; - -/// Run the always-on loop: light sweep every `light_interval_secs`, deep -/// sweep every `deep_interval_secs`, snapshot refreshed after each. -/// Lease enforcement is live (execute=true) — that is the daemon's contract. -pub async fn run_loop( - cfg: Arc, - store: Arc, - log: Arc, - snapshot: SharedSnapshot, -) { - let light = std::time::Duration::from_secs(cfg.light_interval_secs.max(5)); - let deep_every = - std::time::Duration::from_secs(cfg.deep_interval_secs.max(cfg.light_interval_secs.max(5))); - // Deep sweeps are scheduled on elapsed time, not tick counts, so backoff - // stretching the light cadence cannot stretch the deep cadence with it. - let mut next_deep = tokio::time::Instant::now(); - loop { - let deep = tokio::time::Instant::now() >= next_deep; - if deep { - next_deep = tokio::time::Instant::now() + deep_every; - } - let cfg2 = Arc::clone(&cfg); - let store2 = Arc::clone(&store); - let log2 = Arc::clone(&log); - let result = tokio::task::spawn_blocking(move || { - let (procs, _) = scan(&cfg2); - let outcome = sweep_once( - &cfg2, - &store2, - &log2, - true, - deep, - &mut make_verified_killer(&store2), - ); - (procs, outcome) - }) - .await; - match result { - Ok((procs, Ok(outcome))) => { - let mut snap = snapshot.lock().expect("snapshot lock"); - snap.processes = { - let mut list: Vec = procs.into_values().collect(); - list.sort_by_key(|p| std::cmp::Reverse(p.rss_bytes)); - list - }; - snap.last = Some(outcome); - } - Ok((_, Err(err))) => tracing::warn!(%err, "sweep failed"), - Err(err) => tracing::warn!(%err, "sweep task panicked"), - } - // Back off when everything is calm: green pressure doubles the nap, - // but never sleep past the next deep-sweep deadline. - let calm = { - let snap = snapshot.lock().expect("snapshot lock"); - snap.last.as_ref().is_some_and(|o| o.pressure.level == "green") - }; - let nap = if calm { light * 2 } else { light }; - let until_deep = next_deep - .saturating_duration_since(tokio::time::Instant::now()) - .max(std::time::Duration::from_secs(1)); - tokio::time::sleep(nap.min(until_deep)).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dry_run_sweep_on_real_system_is_safe_and_consistent() { - let dir = tempfile::tempdir().unwrap(); - let store = LeaseStore::new(dir.path()); - let log = EventLog::new(dir.path()); - let cfg = Config::default(); - let mut killed = Vec::new(); - let outcome = sweep_once(&cfg, &store, &log, false, true, &mut |pid| { - killed.push(pid); - Ok(()) - }) - .unwrap(); - assert!(killed.is_empty(), "dry run must never kill"); - assert!(outcome.ts > 0); - assert!(!outcome.bucket_counts.is_empty(), "a real system has processes"); - // The sweep must have written its ledger line. - assert!(!log.tail(1).unwrap().is_empty()); - } - - #[test] - fn sweep_drops_leases_for_pids_that_no_longer_exist() { - let dir = tempfile::tempdir().unwrap(); - let store = LeaseStore::new(dir.path()); - let log = EventLog::new(dir.path()); - let cfg = Config::default(); - // A pid that cannot exist: valid range but far beyond real tables. - store - .create( - u32::MAX - 7, - "test", - 60, - crate::model::Identity { exe_name: "ghost.exe".into(), start_time: 1 }, - true, - unix_now(), - ) - .unwrap(); - let outcome = - sweep_once(&cfg, &store, &log, false, false, &mut |_| Ok(())).unwrap(); - assert_eq!(outcome.lease_count, 0); - assert!(store.load().unwrap().is_empty()); - } -} diff --git a/crates/flared/src/events.rs b/crates/flared/src/events.rs deleted file mode 100644 index 28c44885..00000000 --- a/crates/flared/src/events.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Append-only JSONL ledger of everything flared observed and did. - -use std::path::PathBuf; - -const DEFAULT_MAX_BYTES: u64 = 5 * 1024 * 1024; -const TAIL_READ_BYTES: u64 = 256 * 1024; - -pub struct EventLog { - path: PathBuf, - max_bytes: u64, -} - -impl EventLog { - pub fn new(dir: impl Into) -> Self { - Self { path: dir.into().join("events.jsonl"), max_bytes: DEFAULT_MAX_BYTES } - } - - #[cfg(test)] - fn with_max_bytes(dir: impl Into, max_bytes: u64) -> Self { - Self { path: dir.into().join("events.jsonl"), max_bytes } - } - - pub fn path(&self) -> &std::path::Path { - &self.path - } - - /// Append one event line: `{"ts": , "kind": ..., "detail": ...}`. - /// When the ledger exceeds `max_bytes` it rotates to `events.jsonl.1` - /// (single generation) so an always-on daemon never grows it unbounded. - pub fn append(&self, kind: &str, detail: serde_json::Value) -> eyre::Result<()> { - use std::io::Write; - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - if std::fs::metadata(&self.path).map(|m| m.len() >= self.max_bytes).unwrap_or(false) { - let rotated = self.path.with_extension("jsonl.1"); - let _ = std::fs::remove_file(&rotated); - let _ = std::fs::rename(&self.path, &rotated); - } - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let line = serde_json::json!({ "ts": ts, "kind": kind, "detail": detail }); - let mut file = - std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?; - writeln!(file, "{line}")?; - Ok(()) - } - - /// Last `n` events, oldest first. Missing file -> empty. Reads only a - /// bounded window from the end of the file, never the whole ledger. - pub fn tail(&self, n: usize) -> eyre::Result> { - use std::io::{Read, Seek, SeekFrom}; - let mut file = match std::fs::File::open(&self.path) { - Ok(file) => file, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - let len = file.metadata()?.len(); - let start = len.saturating_sub(TAIL_READ_BYTES); - file.seek(SeekFrom::Start(start))?; - let mut text = String::new(); - file.read_to_string(&mut text)?; - // A mid-file seek may land inside a line; drop the partial first line. - let text = if start > 0 { - text.split_once('\n').map(|(_, rest)| rest).unwrap_or("") - } else { - text.as_str() - }; - let events: Vec = text - .lines() - .filter(|l| !l.trim().is_empty()) - .filter_map(|l| serde_json::from_str(l).ok()) - .collect(); - let skip = events.len().saturating_sub(n); - Ok(events.into_iter().skip(skip).collect()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn append_then_tail_returns_last_n_in_order() { - let dir = tempfile::tempdir().unwrap(); - let log = EventLog::new(dir.path()); - for i in 0..3 { - log.append("sweep", serde_json::json!({ "i": i })).unwrap(); - } - let tail = log.tail(2).unwrap(); - assert_eq!(tail.len(), 2); - assert_eq!(tail[0]["detail"]["i"], 1); - assert_eq!(tail[1]["detail"]["i"], 2); - assert_eq!(tail[1]["kind"], "sweep"); - assert!(tail[1]["ts"].is_u64()); - } - - #[test] - fn tail_on_missing_file_is_empty() { - let dir = tempfile::tempdir().unwrap(); - let log = EventLog::new(dir.path()); - assert_eq!(log.tail(10).unwrap(), Vec::::new()); - } - - #[test] - fn ledger_rotates_when_over_max_bytes() { - let dir = tempfile::tempdir().unwrap(); - let log = EventLog::with_max_bytes(dir.path(), 200); - for i in 0..20 { - log.append("sweep", serde_json::json!({ "i": i })).unwrap(); - } - assert!(dir.path().join("events.jsonl.1").exists(), "rotation file missing"); - let main_len = std::fs::metadata(dir.path().join("events.jsonl")).unwrap().len(); - assert!(main_len < 400, "main ledger should have been rotated, is {main_len}"); - // Tail still returns the most recent event. - let tail = log.tail(1).unwrap(); - assert_eq!(tail[0]["detail"]["i"], 19); - } -} diff --git a/crates/flared/src/http.rs b/crates/flared/src/http.rs deleted file mode 100644 index 55283115..00000000 --- a/crates/flared/src/http.rs +++ /dev/null @@ -1,537 +0,0 @@ -//! Localhost HTTP surface. Never binds beyond 127.0.0.1. - -use std::path::PathBuf; -use std::sync::Arc; - -use axum::extract::{Path as AxumPath, Query, Request, State}; -use axum::http::StatusCode; -use axum::middleware::{self, Next}; -use axum::response::{Html, IntoResponse, Response}; -use axum::routing::{delete, get, post}; -use axum::{Json, Router}; -use serde::Deserialize; - -use crate::config::Config; -use crate::daemon::{make_verified_killer, sweep_once, unix_now, SharedSnapshot, SweepOutcome}; -use crate::events::EventLog; -use crate::leases::LeaseStore; -use crate::model::{Finding, Identity, Lease, ProcInfo}; -use crate::scanner::identity_of; - -type HttpError = (StatusCode, String); - -fn internal(err: impl std::fmt::Display) -> HttpError { - (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) -} - -#[derive(Clone)] -pub struct AppState { - pub cfg: Arc, - pub snapshot: SharedSnapshot, - pub store: Arc, - pub log: Arc, - /// Per-install secret required on every mutating request. Guards the - /// kill-enabled endpoints against localhost CSRF: a web page can fire a - /// cross-origin POST at 127.0.0.1, but it cannot read this token, and the - /// custom header forces a CORS preflight that never passes. - pub token: Arc, - /// Shared agentflare artifact store served under /artifacts; None when - /// disabled in config. - pub artifacts: Option>, -} - -impl AppState { - pub fn new(cfg: Arc, state_dir: PathBuf) -> Self { - let artifacts = cfg.artifacts_enabled.then(|| { - Arc::new(agentflare_artifacts::ArtifactStore::new(cfg.artifacts_dir.clone())) - }); - Self { - cfg, - snapshot: SharedSnapshot::default(), - store: Arc::new(LeaseStore::new(&state_dir)), - log: Arc::new(EventLog::new(&state_dir)), - token: Arc::new(load_or_create_token(&state_dir)), - artifacts, - } - } -} - -fn load_or_create_token(dir: &std::path::Path) -> String { - use std::hash::BuildHasher; - let path = dir.join("token"); - if let Ok(existing) = std::fs::read_to_string(&path) { - let existing = existing.trim().to_string(); - if !existing.is_empty() { - return existing; - } - } - // 128 bits from std's randomly-keyed SipHash (RandomState seeds from OS - // entropy) mixed with time and pid. - let mut token = String::with_capacity(32); - for round in 0u8..2 { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let word = std::collections::hash_map::RandomState::new() - .hash_one((nanos, std::process::id(), round)); - token.push_str(&format!("{word:016x}")); - } - let _ = std::fs::create_dir_all(dir); - if let Err(err) = std::fs::write(&path, &token) { - tracing::warn!(%err, "could not persist API token; using session-only token"); - } - token -} - -async fn require_token(State(s): State, req: Request, next: Next) -> Response { - let mutating = matches!(req.method().as_str(), "POST" | "DELETE" | "PUT" | "PATCH"); - if mutating { - let ok = req - .headers() - .get("x-flared-token") - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v == s.token.as_str()); - if !ok { - return ( - StatusCode::UNAUTHORIZED, - "missing or invalid x-flared-token (value lives in flared's state dir)", - ) - .into_response(); - } - } - next.run(req).await -} - -#[derive(Deserialize)] -pub struct LeaseRequest { - pub pid: u32, - #[serde(default = "default_class")] - pub class: String, - pub ttl_seconds: u64, - #[serde(default)] - pub allow_kill: bool, -} - -fn default_class() -> String { - "agent".into() -} - -#[derive(Deserialize)] -pub struct EventsQuery { - #[serde(default = "default_events_n")] - pub n: usize, -} - -fn default_events_n() -> usize { - 50 -} - -pub fn router(state: AppState) -> Router { - let artifacts = state.artifacts.clone(); - let router = Router::new() - .route("/", get(dashboard)) - .route("/status", get(status)) - .route("/processes", get(processes)) - .route("/orphans", get(orphans)) - .route("/events", get(events)) - .route("/leases", get(leases_list)) - .route("/lease", post(lease_create)) - .route("/lease/{id}/heartbeat", post(lease_heartbeat)) - .route("/lease/{id}", delete(lease_delete)) - .route("/sweep", post(sweep_now)) - .route("/clean", post(clean_dry_run)) - .route("/clean/execute", post(clean_execute)) - .layer(middleware::from_fn_with_state(state.clone(), require_token)) - .with_state(state); - match artifacts { - // Merged outside the token layer: the artifact routes are read-only - // GETs, and the guard only gates mutating methods anyway. - Some(store) => router.merge(crate::artifacts::router(store)), - None => router, - } -} - -async fn dashboard() -> Html<&'static str> { - Html(DASHBOARD) -} - -async fn status(State(s): State) -> Json { - let last = s.snapshot.lock().expect("snapshot lock").last.clone(); - let lease_count = s.store.load().map(|l| l.len()).unwrap_or(0); - Json(serde_json::json!({ - "last": last, - "lease_count": lease_count, - "port": s.cfg.port, - })) -} - -async fn processes(State(s): State) -> Json> { - Json(s.snapshot.lock().expect("snapshot lock").processes.clone()) -} - -async fn orphans(State(s): State) -> Json> { - let snap = s.snapshot.lock().expect("snapshot lock"); - Json(snap.last.as_ref().map(|l| l.orphans.clone()).unwrap_or_default()) -} - -async fn events( - State(s): State, - Query(q): Query, -) -> Result>, HttpError> { - s.log.tail(q.n).map(Json).map_err(internal) -} - -async fn leases_list(State(s): State) -> Result>, HttpError> { - s.store.load().map(Json).map_err(internal) -} - -async fn lease_create( - State(s): State, - Json(req): Json, -) -> Result, HttpError> { - let pid = req.pid; - let identity = tokio::task::spawn_blocking(move || identity_of(pid)) - .await - .map_err(internal)? - .ok_or((StatusCode::NOT_FOUND, format!("pid {pid} is not running")))?; - let lease = s - .store - .create( - pid, - &req.class, - req.ttl_seconds, - Identity { exe_name: identity.0, start_time: identity.1 }, - req.allow_kill, - unix_now(), - ) - .map_err(internal)?; - let _ = s.log.append( - "lease.create", - serde_json::json!({ "id": lease.id, "pid": lease.pid, "class": lease.class }), - ); - Ok(Json(lease)) -} - -async fn lease_heartbeat( - State(s): State, - AxumPath(id): AxumPath, -) -> Result, HttpError> { - match s.store.heartbeat(&id, unix_now()).map_err(internal)? { - Some(lease) => Ok(Json(lease)), - None => Err((StatusCode::NOT_FOUND, format!("unknown lease '{id}'"))), - } -} - -async fn lease_delete( - State(s): State, - AxumPath(id): AxumPath, -) -> Result, HttpError> { - let removed = s.store.remove(&id).map_err(internal)?; - let _ = s.log.append("lease.delete", serde_json::json!({ "id": id, "removed": removed })); - Ok(Json(serde_json::json!({ "removed": removed }))) -} - -async fn run_sweep(s: AppState, execute: bool) -> Result, HttpError> { - let worker = s.clone(); - let outcome = tokio::task::spawn_blocking(move || { - sweep_once( - &worker.cfg, - &worker.store, - &worker.log, - execute, - true, - &mut make_verified_killer(&worker.store), - ) - }) - .await - .map_err(internal)? - .map_err(internal)?; - // Publish so /status, /orphans, and the dashboard see manual sweeps too. - s.snapshot.lock().expect("snapshot lock").last = Some(outcome.clone()); - Ok(Json(outcome)) -} - -async fn sweep_now(State(s): State) -> Result, HttpError> { - run_sweep(s, true).await -} - -async fn clean_dry_run(State(s): State) -> Result, HttpError> { - run_sweep(s, false).await -} - -async fn clean_execute(State(s): State) -> Result, HttpError> { - run_sweep(s, true).await -} - -const DASHBOARD: &str = r#" -flared - -

flared

-
loading…
-

workloads

-

orphan findings

-"#; - -/// Serve until aborted. Binds 127.0.0.1 only. -pub async fn serve(state: AppState) -> eyre::Result<()> { - let port = state.cfg.port; - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; - tracing::info!(%port, "flared http listening on 127.0.0.1"); - axum::serve(listener, router(state)).await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use http_body_util::BodyExt; - use tower::ServiceExt; - - fn state() -> (tempfile::TempDir, AppState) { - let dir = tempfile::tempdir().unwrap(); - let cfg = Config { - // keep artifact serving inside the temp dir, not the real store - artifacts_dir: dir.path().join("artifacts-store"), - ..Config::default() - }; - let state = AppState::new(Arc::new(cfg), dir.path().to_path_buf()); - (dir, state) - } - - async fn body_json(response: axum::response::Response) -> serde_json::Value { - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&bytes).unwrap() - } - - #[tokio::test] - async fn artifact_routes_are_mounted_and_need_no_token() { - let (_dir, state) = state(); - let app = router(state); - let response = app - .oneshot( - axum::http::Request::builder() - .uri("/artifacts") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - assert!( - String::from_utf8_lossy(&bytes).contains("agentflare artifacts"), - "artifact index must serve under /artifacts" - ); - } - - #[tokio::test] - async fn artifact_routes_absent_when_disabled() { - let dir = tempfile::tempdir().unwrap(); - let cfg = Config { - artifacts_enabled: false, - artifacts_dir: dir.path().join("artifacts-store"), - ..Config::default() - }; - let state = AppState::new(Arc::new(cfg), dir.path().to_path_buf()); - let app = router(state); - let response = app - .oneshot( - axum::http::Request::builder() - .uri("/artifacts") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn status_serves_snapshot_json() { - let (_dir, state) = state(); - let app = router(state); - let response = app - .oneshot( - axum::http::Request::builder() - .uri("/status") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let json = body_json(response).await; - assert!(json.get("last").is_some()); - assert!(json.get("lease_count").is_some()); - } - - #[tokio::test] - async fn dashboard_serves_html() { - let (_dir, state) = state(); - let app = router(state); - let response = app - .oneshot( - axum::http::Request::builder() - .uri("/") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - } - - #[tokio::test] - async fn lease_lifecycle_over_http() { - let (_dir, state) = state(); - let app = router(state.clone()); - - // Register a lease for OUR OWN live process — identity is resolvable. - let me = std::process::id(); - let token = state.token.as_str().to_string(); - let response = app - .clone() - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri("/lease") - .header("content-type", "application/json") - .header("x-flared-token", &token) - .body(axum::body::Body::from( - serde_json::json!({ "pid": me, "ttl_seconds": 300 }).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let lease = body_json(response).await; - let id = lease["id"].as_str().unwrap().to_string(); - assert_eq!(lease["pid"].as_u64().unwrap() as u32, me); - assert!(!lease["identity"]["exe_name"].as_str().unwrap().is_empty()); - - // Heartbeat. - let response = app - .clone() - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri(format!("/lease/{id}/heartbeat")) - .header("x-flared-token", &token) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - // Delete. - let response = app - .clone() - .oneshot( - axum::http::Request::builder() - .method("DELETE") - .uri(format!("/lease/{id}")) - .header("x-flared-token", &token) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert!(state.store.load().unwrap().is_empty()); - } - - #[tokio::test] - async fn lease_for_dead_pid_is_rejected() { - let (_dir, state) = state(); - let token = state.token.as_str().to_string(); - let app = router(state); - let response = app - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri("/lease") - .header("content-type", "application/json") - .header("x-flared-token", &token) - .body(axum::body::Body::from( - serde_json::json!({ "pid": u32::MAX - 7, "ttl_seconds": 300 }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn mutating_request_without_token_is_unauthorized() { - let (_dir, state) = state(); - let app = router(state.clone()); - for (method, uri) in - [("POST", "/sweep"), ("POST", "/clean/execute"), ("DELETE", "/lease/x")] - { - let response = app - .clone() - .oneshot( - axum::http::Request::builder() - .method(method) - .uri(uri) - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "{method} {uri} must require the token" - ); - } - // Reads stay open. - let response = app - .oneshot( - axum::http::Request::builder() - .uri("/status") - .body(axum::body::Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - } -} diff --git a/crates/flared/src/janitor/lean_ctx.rs b/crates/flared/src/janitor/lean_ctx.rs deleted file mode 100644 index 5986d10f..00000000 --- a/crates/flared/src/janitor/lean_ctx.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! Stale-entry check for lean-ctx's `agents/registry.json` — the source of -//! the "408 active agents" dashboard bug: MCP instances register on start and -//! are never reaped. An entry counts as live only when its PID exists AND the -//! process name matches AND the process start time agrees with the recorded -//! `started_at` (PID-reuse guard). - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::model::ProcInfo; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StaleEntry { - pub agent_id: String, - pub pid: u32, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RegistryReport { - pub total: usize, - pub live: usize, - pub stale: Vec, -} - -/// Parse the registry and classify every entry as live or stale. -/// `expected_exe` is a substring the live process name must contain -/// (case-insensitive), e.g. "lean-ctx". -pub fn check_registry( - path: &Path, - procs: &HashMap, - expected_exe: &str, - tolerance_secs: u64, -) -> eyre::Result { - let text = std::fs::read_to_string(path)?; - let value: serde_json::Value = serde_json::from_str(&text)?; - let agents = value - .get("agents") - .and_then(|a| a.as_array()) - .cloned() - .unwrap_or_default(); - - let expected = expected_exe.to_ascii_lowercase(); - let mut live = 0usize; - let mut stale = Vec::new(); - for entry in &agents { - let agent_id = entry - .get("agent_id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let raw_pid = entry.get("pid").and_then(|v| v.as_u64()); - let Some(pid) = raw_pid.and_then(|v| u32::try_from(v).ok()) else { - stale.push(StaleEntry { - agent_id, - pid: 0, - reason: format!("missing or out-of-range pid ({raw_pid:?})"), - }); - continue; - }; - let recorded_start = entry - .get("started_at") - .and_then(|v| v.as_str()) - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.timestamp().max(0) as u64); - - // Identity requires ALL of: pid alive, expected exe name, and a - // recorded start time that agrees. Missing/unparseable started_at is - // stale — falling through as "live" would bypass the PID-reuse guard. - let stale_reason = match procs.get(&pid) { - None => Some(format!("pid {pid} is not running")), - Some(p) if !p.name.to_ascii_lowercase().contains(&expected) => { - Some(format!("pid {pid} was reused by '{}'", p.name)) - } - Some(p) => match recorded_start { - None => Some("missing or unparseable started_at".to_string()), - Some(t) if p.start_time.abs_diff(t) > tolerance_secs => Some(format!( - "pid {pid} start time {} does not match recorded started_at {t}", - p.start_time - )), - Some(_) => None, - }, - }; - match stale_reason { - Some(reason) => stale.push(StaleEntry { agent_id, pid, reason }), - None => live += 1, - } - } - Ok(RegistryReport { total: agents.len(), live, stale }) -} - -/// Remove the stale entries named in `report`, keeping everything else and -/// all unrelated top-level keys intact. Writes `.bak-` first -/// and returns the backup path. -pub fn prune_registry(path: &Path, report: &RegistryReport) -> eyre::Result { - let text = std::fs::read_to_string(path)?; - let mut value: serde_json::Value = serde_json::from_str(&text)?; - - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let backup = PathBuf::from(format!("{}.bak-{ts}", path.display())); - std::fs::write(&backup, &text)?; - - // Remove by (agent_id, pid) pair, not id alone: a fresh registration that - // happened to reuse an id between check and prune keeps its entry unless - // the pid also matches the stale record. - let stale_keys: std::collections::HashSet<(&str, u64)> = - report.stale.iter().map(|s| (s.agent_id.as_str(), s.pid as u64)).collect(); - if let Some(agents) = value.get_mut("agents").and_then(|a| a.as_array_mut()) { - agents.retain(|entry| { - let id = entry.get("agent_id").and_then(|v| v.as_str()); - let pid = entry.get("pid").and_then(|v| v.as_u64()); - match (id, pid) { - (Some(id), Some(pid)) => !stale_keys.contains(&(id, pid)), - // Entries with a broken pid were reported with pid 0. - (Some(id), None) => !stale_keys.contains(&(id, 0)), - _ => true, - } - }); - } - - let file_name = path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); - let tmp = path.with_file_name(format!("{file_name}.tmp")); - std::fs::write(&tmp, serde_json::to_string_pretty(&value)?)?; - std::fs::rename(&tmp, path)?; - Ok(backup) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::Bucket; - use pretty_assertions::assert_eq; - - fn proc(pid: u32, name: &str, start: u64) -> ProcInfo { - ProcInfo { - pid, - ppid: Some(1), - name: name.into(), - cmd: name.into(), - start_time: start, - cpu_pct: 0.0, - rss_bytes: 0, - bucket: Bucket::Agents, - protected: false, - } - } - - /// 2000-01-01T00:00:00Z = 946684800 unix. - const T0: u64 = 946684800; - - fn registry_json() -> String { - // Three entries: pid 100 live-and-matching, pid 200 gone, - // pid 300 reused by an unrelated exe. - format!( - r#"{{ - "agents": [ - {{"agent_id": "mcp-100", "agent_type": "mcp", "role": "coder", - "project_root": "C:/w/a", "started_at": "2000-01-01T00:00:00Z", - "last_active": "2000-01-01T00:00:00Z", "pid": 100, - "status": "Active", "status_message": null}}, - {{"agent_id": "mcp-200", "agent_type": "mcp", "role": "coder", - "project_root": "C:/w/b", "started_at": "2000-01-01T00:00:00Z", - "last_active": "2000-01-01T00:00:00Z", "pid": 200, - "status": "Active", "status_message": null}}, - {{"agent_id": "mcp-300", "agent_type": "mcp", "role": "coder", - "project_root": "C:/w/c", "started_at": "2000-01-01T00:00:00Z", - "last_active": "2000-01-01T00:00:00Z", "pid": 300, - "status": "Active", "status_message": null}} - ], - "scratchpad": [], - "updated_at": "2000-01-02T00:00:00.000000000Z" -}}"# - ) - } - - fn write_registry(dir: &tempfile::TempDir) -> PathBuf { - let path = dir.path().join("registry.json"); - std::fs::write(&path, registry_json()).unwrap(); - path - } - - fn live_procs() -> HashMap { - [ - (100, proc(100, "lean-ctx.exe", T0)), - (300, proc(300, "spotify.exe", T0 + 90000)), - ] - .into_iter() - .collect() - } - - #[test] - fn classifies_live_dead_and_reused_pids() { - let dir = tempfile::tempdir().unwrap(); - let path = write_registry(&dir); - let report = check_registry(&path, &live_procs(), "lean-ctx", 5).unwrap(); - assert_eq!(report.total, 3); - assert_eq!(report.live, 1); - let stale_ids: Vec<&str> = report.stale.iter().map(|s| s.agent_id.as_str()).collect(); - assert_eq!(stale_ids, vec!["mcp-200", "mcp-300"]); - } - - #[test] - fn start_time_mismatch_counts_as_stale() { - // Right exe name, but the process started much later than the - // registry claims -> recycled pid slot reused by a NEW lean-ctx. - let dir = tempfile::tempdir().unwrap(); - let path = write_registry(&dir); - let procs: HashMap = - [(100, proc(100, "lean-ctx.exe", T0 + 3600))].into_iter().collect(); - let report = check_registry(&path, &procs, "lean-ctx", 5).unwrap(); - assert_eq!(report.live, 0); - assert_eq!(report.stale.len(), 3); - } - - #[test] - fn out_of_range_pid_and_missing_started_at_are_stale() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("registry.json"); - // Entry 1: pid exceeds u32 (would previously wrap). Entry 2: live pid - // + right name, but no started_at (would previously pass as live). - std::fs::write( - &path, - r#"{"agents": [ - {"agent_id": "a-big", "pid": 4294967297, "started_at": "2000-01-01T00:00:00Z", "status": "Active"}, - {"agent_id": "a-nostart", "pid": 100, "status": "Active"} - ]}"#, - ) - .unwrap(); - let procs: HashMap = - [(100, proc(100, "lean-ctx.exe", T0))].into_iter().collect(); - let report = check_registry(&path, &procs, "lean-ctx", 5).unwrap(); - assert_eq!(report.live, 0); - assert_eq!(report.stale.len(), 2); - } - - #[test] - fn prune_removes_stale_keeps_live_and_other_keys() { - let dir = tempfile::tempdir().unwrap(); - let path = write_registry(&dir); - let report = check_registry(&path, &live_procs(), "lean-ctx", 5).unwrap(); - let backup = prune_registry(&path, &report).unwrap(); - - assert!(backup.exists(), "backup must be written before mutation"); - let after: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - let agents = after["agents"].as_array().unwrap(); - assert_eq!(agents.len(), 1); - assert_eq!(agents[0]["agent_id"], "mcp-100"); - // Unrelated top-level keys survive. - assert!(after.get("scratchpad").is_some()); - assert!(after.get("updated_at").is_some()); - - let before: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&backup).unwrap()).unwrap(); - assert_eq!(before["agents"].as_array().unwrap().len(), 3); - } -} diff --git a/crates/flared/src/janitor/mod.rs b/crates/flared/src/janitor/mod.rs deleted file mode 100644 index c98c1ac5..00000000 --- a/crates/flared/src/janitor/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! File-level cleanup plugins that run on the deep sweep. Each janitor -//! reports what it would do; mutation requires an explicit execute call and -//! always writes a backup first. - -pub mod lean_ctx; diff --git a/crates/flared/src/leases.rs b/crates/flared/src/leases.rs deleted file mode 100644 index e8be38e4..00000000 --- a/crates/flared/src/leases.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::model::{Identity, Lease}; - -/// JSON-file-backed lease store with atomic writes and corrupt-file -/// quarantine. All timestamps are seconds since the unix epoch and are passed -/// in by callers so the logic stays deterministic under test. -/// -/// Every operation holds an internal mutex, so concurrent HTTP handlers and -/// the sweep loop within one process cannot interleave load-modify-save and -/// lose a heartbeat. Cross-process writers still race (last writer wins) but -/// cannot corrupt the file: writes go to a per-process temp file + rename. -pub struct LeaseStore { - dir: PathBuf, - guard: std::sync::Mutex<()>, -} - -impl LeaseStore { - pub fn new(dir: impl Into) -> Self { - Self { dir: dir.into(), guard: std::sync::Mutex::new(()) } - } - - pub fn path(&self) -> PathBuf { - self.dir.join("leases.json") - } - - /// Load all leases. A missing file is an empty store. A corrupt file is - /// moved aside to `leases.json.quarantine` and treated as empty — the - /// supervisor must never crash-loop on bad state. - pub fn load(&self) -> eyre::Result> { - let _guard = self.guard.lock().expect("lease store lock"); - self.load_unlocked() - } - - fn load_unlocked(&self) -> eyre::Result> { - let path = self.path(); - let text = match std::fs::read_to_string(&path) { - Ok(text) => text, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - match serde_json::from_str(&text) { - Ok(leases) => Ok(leases), - Err(err) => { - tracing::warn!(path = %path.display(), %err, "corrupt lease file, quarantining"); - let quarantine = self.quarantine_path(); - let _ = std::fs::remove_file(&quarantine); - std::fs::rename(&path, &quarantine)?; - Ok(Vec::new()) - } - } - } - - /// Atomic save: write to a per-process temp file in the same directory, - /// then rename over the target. - pub fn save(&self, leases: &[Lease]) -> eyre::Result<()> { - let _guard = self.guard.lock().expect("lease store lock"); - self.save_unlocked(leases) - } - - fn save_unlocked(&self, leases: &[Lease]) -> eyre::Result<()> { - std::fs::create_dir_all(&self.dir)?; - let tmp = self.dir.join(format!("leases.json.tmp-{}", std::process::id())); - std::fs::write(&tmp, serde_json::to_vec_pretty(leases)?)?; - std::fs::rename(&tmp, self.path())?; - Ok(()) - } - - pub fn create( - &self, - pid: u32, - class: &str, - ttl_seconds: u64, - identity: Identity, - allow_kill: bool, - now: u64, - ) -> eyre::Result { - let _guard = self.guard.lock().expect("lease store lock"); - let mut leases = self.load_unlocked()?; - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos()) - .unwrap_or(0); - let lease = Lease { - id: format!("l-{pid}-{now}-{nanos:x}"), - pid, - class: class.to_string(), - created_at: now, - ttl_seconds, - identity, - allow_kill, - }; - leases.push(lease.clone()); - self.save_unlocked(&leases)?; - Ok(lease) - } - - /// Reset the lease clock. Returns the refreshed lease, or None when the - /// id is unknown. - pub fn heartbeat(&self, id: &str, now: u64) -> eyre::Result> { - let _guard = self.guard.lock().expect("lease store lock"); - let mut leases = self.load_unlocked()?; - let Some(lease) = leases.iter_mut().find(|l| l.id == id) else { - return Ok(None); - }; - lease.created_at = now; - let refreshed = lease.clone(); - self.save_unlocked(&leases)?; - Ok(Some(refreshed)) - } - - pub fn remove(&self, id: &str) -> eyre::Result { - let _guard = self.guard.lock().expect("lease store lock"); - let mut leases = self.load_unlocked()?; - let before = leases.len(); - leases.retain(|l| l.id != id); - let removed = leases.len() < before; - if removed { - self.save_unlocked(&leases)?; - } - Ok(removed) - } - - pub fn expired(&self, now: u64) -> eyre::Result> { - Ok(self.load()?.into_iter().filter(|l| l.expires_at() <= now).collect()) - } - - pub fn quarantine_path(&self) -> PathBuf { - self.dir.join("leases.json.quarantine") - } -} - -pub fn default_state_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| Path::new(".").to_path_buf()) - .join("flared") -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn store() -> (tempfile::TempDir, LeaseStore) { - let dir = tempfile::tempdir().unwrap(); - let store = LeaseStore::new(dir.path()); - (dir, store) - } - - fn identity() -> Identity { - Identity { exe_name: "agent.exe".into(), start_time: 500 } - } - - #[test] - fn create_then_load_roundtrips() { - let (_dir, store) = store(); - let lease = store.create(4242, "build", 600, identity(), true, 1000).unwrap(); - let loaded = store.load().unwrap(); - assert_eq!(loaded, vec![lease]); - } - - #[test] - fn load_on_missing_file_is_empty() { - let (_dir, store) = store(); - assert_eq!(store.load().unwrap(), vec![]); - } - - #[test] - fn expired_returns_only_past_ttl() { - let (_dir, store) = store(); - store.create(1, "a", 100, identity(), true, 1000).unwrap(); - let old = store.create(2, "b", 100, identity(), true, 200).unwrap(); - let expired = store.expired(1050).unwrap(); - assert_eq!(expired, vec![old]); - } - - #[test] - fn heartbeat_extends_expiry() { - let (_dir, store) = store(); - let lease = store.create(1, "a", 100, identity(), true, 1000).unwrap(); - store.heartbeat(&lease.id, 1090).unwrap().unwrap(); - assert_eq!(store.expired(1150).unwrap(), vec![]); - assert_eq!(store.expired(1191).unwrap().len(), 1); - } - - #[test] - fn heartbeat_unknown_id_is_none() { - let (_dir, store) = store(); - assert!(store.heartbeat("nope", 1000).unwrap().is_none()); - } - - #[test] - fn remove_deletes_lease() { - let (_dir, store) = store(); - let lease = store.create(1, "a", 100, identity(), true, 1000).unwrap(); - assert!(store.remove(&lease.id).unwrap()); - assert_eq!(store.load().unwrap(), vec![]); - assert!(!store.remove(&lease.id).unwrap()); - } - - #[test] - fn corrupt_file_is_quarantined_not_fatal() { - let (_dir, store) = store(); - std::fs::create_dir_all(store.path().parent().unwrap()).unwrap(); - std::fs::write(store.path(), "{not json").unwrap(); - assert_eq!(store.load().unwrap(), vec![]); - assert!(store.quarantine_path().exists(), "corrupt file should be moved aside"); - assert!(!store.path().exists()); - } -} diff --git a/crates/flared/src/lib.rs b/crates/flared/src/lib.rs deleted file mode 100644 index 8f3037d6..00000000 --- a/crates/flared/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! flared — always-on supervisor for AI-agent workload hygiene. -//! -//! Lifecycle: audit -> classify -> protect -> lease -> clean. -//! Safety invariant: flared only ever auto-kills processes it holds a valid -//! lease for, and only after re-verifying the process identity fingerprint -//! (exe name + start time) so a reused PID is never killed by mistake. - -pub mod actions; -pub mod artifacts; -pub mod config; -pub mod daemon; -pub mod events; -pub mod http; -pub mod janitor; -pub mod leases; -pub mod model; -pub mod policy; -pub mod scanner; -pub mod service; diff --git a/crates/flared/src/main.rs b/crates/flared/src/main.rs deleted file mode 100644 index b6243f57..00000000 --- a/crates/flared/src/main.rs +++ /dev/null @@ -1,329 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use clap::{Parser, Subcommand}; -use flared::config::Config; -use flared::daemon::{make_verified_killer, sweep_once, unix_now}; -use flared::events::EventLog; -use flared::http::AppState; -use flared::janitor::lean_ctx::{check_registry, prune_registry}; -use flared::leases::{default_state_dir, LeaseStore}; -use flared::model::Identity; -use flared::scanner::{identity_of, scan}; - -#[derive(Parser)] -#[command(name = "flared", version, about = "Always-on supervisor for AI-agent workload hygiene")] -struct Cli { - /// Path to config.toml (default: ~/.config/flared/config.toml) - #[arg(long, global = true)] - config: Option, - /// Machine-readable JSON output where supported - #[arg(long, global = true)] - json: bool, - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// One-shot pressure + workload summary - Status, - /// Hottest processes by memory - Ps { - #[arg(long, default_value_t = 15)] - top: usize, - }, - /// Plan cleanup; dry run unless --execute - Clean { - /// Actually kill Safe, identity-verified expired leases - #[arg(long)] - execute: bool, - }, - /// Heuristic orphan findings (report-only) - Orphans, - /// Check configured state registries for stale entries - RegistryCheck { - /// Prune stale entries (backup written first) - #[arg(long)] - execute: bool, - }, - /// Manage leases - Lease { - #[command(subcommand)] - command: LeaseCommand, - }, - /// Run the always-on supervisor (sweep loop + HTTP on 127.0.0.1) - Serve { - #[arg(long)] - port: Option, - }, - /// Autostart recipes - Service { - #[command(subcommand)] - command: ServiceCommand, - }, -} - -#[derive(Subcommand)] -enum LeaseCommand { - /// Register a lease for a live pid - Add { - #[arg(long)] - pid: u32, - #[arg(long, default_value = "agent")] - class: String, - /// TTL in seconds - #[arg(long)] - ttl: u64, - /// Authorize flared to kill the tree when the lease expires - #[arg(long)] - allow_kill: bool, - }, - List, - /// Reset a lease's clock - Heartbeat { id: String }, - Remove { id: String }, -} - -#[derive(Subcommand)] -enum ServiceCommand { - /// Print the autostart recipe for this platform - Print, -} - -fn main() -> eyre::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) - .init(); - let cli = Cli::parse(); - let cfg = Config::load(cli.config.as_deref()); - let state_dir = default_state_dir(); - let store = LeaseStore::new(&state_dir); - let log = EventLog::new(&state_dir); - - match cli.command { - Command::Status => { - let outcome = sweep_once(&cfg, &store, &log, false, true, &mut make_verified_killer(&store))?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&outcome)?); - } else { - let p = &outcome.pressure; - println!("pressure: {}", p.level); - println!( - " cpu {:.0}% mem free {:.1}/{:.1} GiB swap {:.1}/{:.1} GiB", - p.cpu_pct, - gib(p.avail_mem_bytes), - gib(p.total_mem_bytes), - gib(p.swap_used_bytes), - gib(p.swap_total_bytes), - ); - let mut buckets: Vec<_> = outcome.bucket_counts.iter().collect(); - buckets.sort_by(|a, b| b.1.cmp(a.1)); - for (bucket, count) in buckets { - println!(" {bucket:<10} {count}"); - } - println!("leases: {} orphan findings: {}", outcome.lease_count, outcome.orphans.len()); - for reg in &outcome.registries { - println!( - "registry {}: {} entries, {} live, {} stale", - reg.path, reg.report.total, reg.report.live, reg.report.stale.len() - ); - } - } - } - Command::Ps { top } => { - let (procs, _) = scan(&cfg); - let mut list: Vec<_> = procs.into_values().collect(); - list.sort_by_key(|p| std::cmp::Reverse(p.rss_bytes)); - println!("{:>8} {:>8} {:>7} {:<10} name", "pid", "rss MiB", "cpu%", "bucket"); - for p in list.into_iter().take(top) { - println!( - "{:>8} {:>8.0} {:>7.1} {:<10} {}{}", - p.pid, - p.rss_bytes as f64 / (1 << 20) as f64, - p.cpu_pct, - format!("{:?}", p.bucket).to_ascii_lowercase(), - p.name, - if p.protected { " [protected]" } else { "" }, - ); - } - } - Command::Clean { execute } => { - let outcome = sweep_once(&cfg, &store, &log, execute, true, &mut make_verified_killer(&store))?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&outcome.outcomes)?); - } else if outcome.actions.is_empty() { - println!("nothing to clean: no expired leases"); - } else { - for o in &outcome.outcomes { - println!( - "[{}] {} — {} ({})", - if o.executed { "KILLED" } else { "planned" }, - o.action.target, - o.action.reason, - o.detail, - ); - } - if !execute { - println!("dry run — pass --execute to act on Safe actions"); - } - } - } - Command::Orphans => { - let outcome = sweep_once(&cfg, &store, &log, false, true, &mut make_verified_killer(&store))?; - if cli.json { - println!("{}", serde_json::to_string_pretty(&outcome.orphans)?); - } else if outcome.orphans.is_empty() { - println!("no orphan findings"); - } else { - for f in &outcome.orphans { - println!("{:>8} {} — {}", f.pid, f.name, f.reason); - } - println!("(report-only; nothing was killed)"); - } - } - Command::RegistryCheck { execute } => { - let (procs, _) = scan(&cfg); - let mut results = Vec::new(); - for reg in &cfg.registries { - // Same filter as the daemon: only formats we understand may - // ever be parsed, let alone pruned. - if reg.kind != "lean-ctx" { - if !cli.json { - println!("skip {} (unsupported kind '{}')", reg.path.display(), reg.kind); - } - results.push(serde_json::json!({ - "path": reg.path.display().to_string(), - "skipped": format!("unsupported kind '{}'", reg.kind), - })); - continue; - } - if !reg.path.exists() { - if !cli.json { - println!("skip {} (missing)", reg.path.display()); - } - results.push(serde_json::json!({ - "path": reg.path.display().to_string(), - "skipped": "missing", - })); - continue; - } - let report = - check_registry(®.path, &procs, ®.expected_exe, cfg.identity_tolerance_secs)?; - if !cli.json { - println!( - "{}: {} entries, {} live, {} stale", - reg.path.display(), - report.total, - report.live, - report.stale.len() - ); - for s in report.stale.iter().take(10) { - println!(" stale {} (pid {}): {}", s.agent_id, s.pid, s.reason); - } - if report.stale.len() > 10 { - println!(" … and {} more", report.stale.len() - 10); - } - } - let mut backup_path = None; - if execute && !report.stale.is_empty() { - let backup = prune_registry(®.path, &report)?; - log.append( - "registry.prune", - serde_json::json!({ - "path": reg.path.display().to_string(), - "removed": report.stale.len(), - "backup": backup.display().to_string(), - }), - )?; - if !cli.json { - println!( - "pruned {} stale entries (backup: {})", - report.stale.len(), - backup.display() - ); - } - backup_path = Some(backup.display().to_string()); - } - results.push(serde_json::json!({ - "path": reg.path.display().to_string(), - "report": report, - "pruned": execute && backup_path.is_some(), - "backup": backup_path, - })); - } - if cli.json { - // One valid JSON document, whatever the number of registries. - println!("{}", serde_json::to_string_pretty(&results)?); - } - } - Command::Lease { command } => match command { - LeaseCommand::Add { pid, class, ttl, allow_kill } => { - let Some((exe_name, start_time)) = identity_of(pid) else { - eyre::bail!("pid {pid} is not running"); - }; - let lease = store.create( - pid, - &class, - ttl, - Identity { exe_name, start_time }, - allow_kill, - unix_now(), - )?; - println!("{}", serde_json::to_string_pretty(&lease)?); - } - LeaseCommand::List => { - println!("{}", serde_json::to_string_pretty(&store.load()?)?); - } - LeaseCommand::Heartbeat { id } => match store.heartbeat(&id, unix_now())? { - Some(lease) => println!("{}", serde_json::to_string_pretty(&lease)?), - None => eyre::bail!("unknown lease '{id}'"), - }, - LeaseCommand::Remove { id } => { - println!("removed: {}", store.remove(&id)?); - } - }, - Command::Serve { port } => { - let mut cfg = cfg; - if let Some(port) = port { - cfg.port = port; - } - let cfg = Arc::new(cfg); - let state = AppState::new(Arc::clone(&cfg), state_dir); - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async { - let loop_handle = tokio::spawn(flared::daemon::run_loop( - Arc::clone(&state.cfg), - Arc::clone(&state.store), - Arc::clone(&state.log), - state.snapshot.clone(), - )); - let result = flared::http::serve(state).await; - loop_handle.abort(); - result - })?; - } - Command::Service { command: ServiceCommand::Print } => { - let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("flared")); - let platform = if cfg!(windows) { - "windows" - } else if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "linux") { - "linux" - } else { - // Preserve the manual-run fallback on other unixes. - std::env::consts::OS - }; - println!("{}", flared::service::autostart_recipe(platform, &exe)); - } - } - Ok(()) -} - -fn gib(bytes: u64) -> f64 { - bytes as f64 / (1u64 << 30) as f64 -} diff --git a/crates/flared/src/model.rs b/crates/flared/src/model.rs deleted file mode 100644 index fb013320..00000000 --- a/crates/flared/src/model.rs +++ /dev/null @@ -1,162 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Fingerprint recorded when a lease is taken. A PID alone is never trusted: -/// PIDs are recycled by every OS, so a kill is only valid while the live -/// process still matches this fingerprint. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Identity { - pub exe_name: String, - /// Process start time, seconds since the unix epoch. - pub start_time: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Bucket { - Agents, - Browsers, - Terminals, - Desktop, - Build, - Services, - Other, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ProcInfo { - pub pid: u32, - pub ppid: Option, - pub name: String, - pub cmd: String, - pub start_time: u64, - pub cpu_pct: f32, - pub rss_bytes: u64, - pub bucket: Bucket, - pub protected: bool, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Lease { - pub id: String, - pub pid: u32, - pub class: String, - /// Seconds since the unix epoch. Heartbeats reset this. - pub created_at: u64, - pub ttl_seconds: u64, - pub identity: Identity, - pub allow_kill: bool, -} - -impl Lease { - pub fn expires_at(&self) -> u64 { - // Saturate: a huge client-supplied TTL must mean "never expires", - // not wrap into an immediately-expired (killable) lease. - self.created_at.saturating_add(self.ttl_seconds) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ActionKind { - KillTree, - Review, - PruneRegistryEntry, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Risk { - /// Owned lease, identity verified: eligible for automatic execution. - Safe, - /// Never executed automatically; surfaced for a human. - ReviewOnly, - /// Recorded but refused even with --execute. - Blocked, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Action { - pub kind: ActionKind, - pub target: String, - pub reason: String, - pub pid: Option, - pub risk: Risk, -} - -/// A heuristic orphan detection. Findings are report-only; they never become -/// automatic kills. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Finding { - pub pid: u32, - pub name: String, - pub reason: String, -} - -/// True when the live process still matches the recorded fingerprint. -/// Exe names compare case-insensitively (Windows reports mixed case), and -/// start times may drift by rounding, so `tolerance_secs` bounds the gap. -pub fn identity_matches( - identity: &Identity, - live_name: &str, - live_start_time: u64, - tolerance_secs: u64, -) -> bool { - identity.exe_name.eq_ignore_ascii_case(live_name) - && identity.start_time.abs_diff(live_start_time) <= tolerance_secs -} - -#[cfg(test)] -mod tests { - use super::*; - - fn identity(name: &str, start: u64) -> Identity { - Identity { exe_name: name.into(), start_time: start } - } - - #[test] - fn matches_same_name_and_start_time() { - assert!(identity_matches(&identity("lean-ctx.exe", 1000), "lean-ctx.exe", 1000, 2)); - } - - #[test] - fn matches_case_insensitively() { - assert!(identity_matches(&identity("Lean-Ctx.EXE", 1000), "lean-ctx.exe", 1000, 2)); - } - - #[test] - fn matches_start_time_within_tolerance() { - assert!(identity_matches(&identity("a.exe", 1000), "a.exe", 1002, 2)); - assert!(identity_matches(&identity("a.exe", 1002), "a.exe", 1000, 2)); - } - - #[test] - fn rejects_start_time_beyond_tolerance() { - assert!(!identity_matches(&identity("a.exe", 1000), "a.exe", 1003, 2)); - } - - #[test] - fn huge_ttl_saturates_instead_of_wrapping() { - let lease = Lease { - id: "l".into(), - pid: 1, - class: "a".into(), - created_at: 100, - ttl_seconds: u64::MAX, - identity: identity("a.exe", 1), - allow_kill: true, - }; - assert_eq!(lease.expires_at(), u64::MAX); - } - - #[test] - fn zero_tolerance_requires_exact_start_time() { - assert!(identity_matches(&identity("a.exe", 1000), "a.exe", 1000, 0)); - assert!(!identity_matches(&identity("a.exe", 1000), "a.exe", 1001, 0)); - } - - #[test] - fn rejects_different_exe_name_even_with_same_pid_slot() { - // The PID-reuse case: same pid, different program now running there. - assert!(!identity_matches(&identity("lean-ctx.exe", 1000), "spotify.exe", 1000, 2)); - } -} diff --git a/crates/flared/src/policy.rs b/crates/flared/src/policy.rs deleted file mode 100644 index ede53b18..00000000 --- a/crates/flared/src/policy.rs +++ /dev/null @@ -1,255 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::model::{identity_matches, Action, ActionKind, Finding, Lease, ProcInfo, Risk}; - -/// Heuristic rule for spotting likely AI-tool orphans. Findings produced by -/// these rules are report-only — never automatic kills. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OrphanRule { - /// Regex matched against the process name. - pub name_pattern: String, - /// Only flag when the parent process is gone. - pub require_dead_parent: bool, - /// Minimum process age in seconds before it can be flagged. - pub min_age_secs: u64, -} - -/// Turn expired leases into actions. The safety core: -/// - identity verified against the live process -> KillTree, risk Safe -/// - pid gone -> no action (nothing to do; lease is pruned elsewhere) -/// - pid present but identity mismatch (PID reuse) -> Review, risk Blocked -/// - live process marked protected -> Review, risk Blocked -/// - lease without allow_kill -> Review, risk ReviewOnly -pub fn plan_lease_actions( - expired: &[Lease], - procs: &HashMap, - tolerance_secs: u64, -) -> Vec { - let mut actions = Vec::new(); - for lease in expired { - let target = format!("lease:{}", lease.id); - let Some(live) = procs.get(&lease.pid) else { - // Process already gone; nothing to kill, lease gets pruned. - continue; - }; - if live.protected { - actions.push(Action { - kind: ActionKind::Review, - target, - reason: format!("expired {} lease points at protected process '{}'", lease.class, live.name), - pid: Some(lease.pid), - risk: Risk::Blocked, - }); - continue; - } - if !identity_matches(&lease.identity, &live.name, live.start_time, tolerance_secs) { - actions.push(Action { - kind: ActionKind::Review, - target, - reason: format!( - "pid {} no longer matches lease identity (now '{}'): likely PID reuse", - lease.pid, live.name - ), - pid: Some(lease.pid), - risk: Risk::Blocked, - }); - continue; - } - if !lease.allow_kill { - actions.push(Action { - kind: ActionKind::Review, - target, - reason: format!("expired {} lease (owner did not authorize kill)", lease.class), - pid: Some(lease.pid), - risk: Risk::ReviewOnly, - }); - continue; - } - actions.push(Action { - kind: ActionKind::KillTree, - target, - reason: format!("expired {} lease, identity verified", lease.class), - pid: Some(lease.pid), - risk: Risk::Safe, - }); - } - actions -} - -/// Report-only orphan detection over the classified process table. -/// Protected processes are never flagged. -pub fn detect_orphans( - procs: &HashMap, - rules: &[OrphanRule], - now: u64, -) -> Vec { - let compiled: Vec<(regex::Regex, &OrphanRule)> = rules - .iter() - .filter_map(|rule| match regex::Regex::new(&rule.name_pattern) { - Ok(re) => Some((re, rule)), - Err(err) => { - tracing::warn!(pattern = %rule.name_pattern, %err, "invalid orphan rule pattern, skipping"); - None - } - }) - .collect(); - - let mut findings = Vec::new(); - for live in procs.values() { - if live.protected { - continue; - } - for (re, rule) in &compiled { - if !re.is_match(&live.name) { - continue; - } - let age = now.saturating_sub(live.start_time); - if age < rule.min_age_secs { - continue; - } - if rule.require_dead_parent { - let parent_alive = live.ppid.is_some_and(|pp| procs.contains_key(&pp)); - if parent_alive { - continue; - } - } - findings.push(Finding { - pid: live.pid, - name: live.name.clone(), - reason: format!( - "matches orphan rule '{}' (age {age}s{})", - rule.name_pattern, - if rule.require_dead_parent { ", parent dead" } else { "" } - ), - }); - break; - } - } - findings.sort_by_key(|f| f.pid); - findings -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::{ActionKind, Bucket, Identity, Risk}; - use pretty_assertions::assert_eq; - - fn proc(pid: u32, ppid: Option, name: &str, start: u64) -> ProcInfo { - ProcInfo { - pid, - ppid, - name: name.into(), - cmd: name.into(), - start_time: start, - cpu_pct: 0.0, - rss_bytes: 0, - bucket: Bucket::Agents, - protected: false, - } - } - - fn lease(pid: u32, name: &str, start: u64, allow_kill: bool) -> Lease { - Lease { - id: format!("l-{pid}"), - pid, - class: "agent".into(), - created_at: 0, - ttl_seconds: 10, - identity: Identity { exe_name: name.into(), start_time: start }, - allow_kill, - } - } - - fn table(entries: Vec) -> HashMap { - entries.into_iter().map(|p| (p.pid, p)).collect() - } - - #[test] - fn verified_expired_lease_becomes_safe_kill() { - let procs = table(vec![proc(10, Some(1), "agent.exe", 700)]); - let actions = plan_lease_actions(&[lease(10, "agent.exe", 700, true)], &procs, 2); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].kind, ActionKind::KillTree); - assert_eq!(actions[0].risk, Risk::Safe); - assert_eq!(actions[0].pid, Some(10)); - } - - #[test] - fn pid_reuse_is_blocked_never_killed() { - // Lease was for agent.exe, but pid 10 is now someone else's process. - let procs = table(vec![proc(10, Some(1), "totally-different.exe", 9999)]); - let actions = plan_lease_actions(&[lease(10, "agent.exe", 700, true)], &procs, 2); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].kind, ActionKind::Review); - assert_eq!(actions[0].risk, Risk::Blocked); - } - - #[test] - fn dead_pid_produces_no_action() { - let procs = table(vec![]); - let actions = plan_lease_actions(&[lease(10, "agent.exe", 700, true)], &procs, 2); - assert_eq!(actions, vec![]); - } - - #[test] - fn protected_process_is_blocked_even_with_valid_lease() { - let mut p = proc(10, Some(1), "agent.exe", 700); - p.protected = true; - let procs = table(vec![p]); - let actions = plan_lease_actions(&[lease(10, "agent.exe", 700, true)], &procs, 2); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].risk, Risk::Blocked); - } - - #[test] - fn lease_without_allow_kill_is_review_only() { - let procs = table(vec![proc(10, Some(1), "agent.exe", 700)]); - let actions = plan_lease_actions(&[lease(10, "agent.exe", 700, false)], &procs, 2); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].kind, ActionKind::Review); - assert_eq!(actions[0].risk, Risk::ReviewOnly); - } - - fn rule(pattern: &str, dead_parent: bool, min_age: u64) -> OrphanRule { - OrphanRule { - name_pattern: pattern.into(), - require_dead_parent: dead_parent, - min_age_secs: min_age, - } - } - - #[test] - fn orphan_rule_flags_old_agent_with_dead_parent() { - // ppid 999 is not in the table -> parent is dead. - let procs = table(vec![proc(10, Some(999), "mcp-server.exe", 100)]); - let findings = detect_orphans(&procs, &[rule("mcp-", true, 60)], 1000); - assert_eq!(findings.len(), 1); - assert_eq!(findings[0].pid, 10); - } - - #[test] - fn orphan_rule_skips_young_processes() { - let procs = table(vec![proc(10, Some(999), "mcp-server.exe", 990)]); - assert_eq!(detect_orphans(&procs, &[rule("mcp-", true, 60)], 1000), vec![]); - } - - #[test] - fn orphan_rule_skips_live_parent_when_dead_parent_required() { - let procs = table(vec![ - proc(1, None, "init", 0), - proc(10, Some(1), "mcp-server.exe", 100), - ]); - assert_eq!(detect_orphans(&procs, &[rule("mcp-", true, 60)], 1000), vec![]); - } - - #[test] - fn orphan_rule_never_flags_protected() { - let mut p = proc(10, Some(999), "mcp-server.exe", 100); - p.protected = true; - let procs = table(vec![p]); - assert_eq!(detect_orphans(&procs, &[rule("mcp-", true, 60)], 1000), vec![]); - } -} diff --git a/crates/flared/src/scanner.rs b/crates/flared/src/scanner.rs deleted file mode 100644 index 4cf5d86a..00000000 --- a/crates/flared/src/scanner.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Process audit: enumerate via sysinfo, classify into workload buckets, -//! mark protected classes, and summarize memory/CPU pressure. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::config::Config; -use crate::model::{Bucket, ProcInfo}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pressure { - pub total_mem_bytes: u64, - pub avail_mem_bytes: u64, - pub swap_total_bytes: u64, - pub swap_used_bytes: u64, - pub cpu_pct: f32, - /// green | yellow | red - pub level: String, -} - -/// Bucket + protected flag for one process, from its name/cmdline. -/// Priority: agents > build > browsers > terminals > desktop > other. -/// Browsers, terminals, and desktop are always protected, as is anything -/// matching `protect_patterns`. -pub fn classify(name: &str, cmd: &str, cfg: &Config) -> (Bucket, bool) { - fn matches(patterns: &[String], haystack: &str) -> bool { - patterns.iter().any(|p| haystack.contains(&p.to_ascii_lowercase())) - } - let name_lc = name.to_ascii_lowercase(); - let cmd_lc = cmd.to_ascii_lowercase(); - - let bucket = if matches(&cfg.agent_patterns, &name_lc) || matches(&cfg.agent_patterns, &cmd_lc) - { - Bucket::Agents - } else if matches(&cfg.build_patterns, &name_lc) { - Bucket::Build - } else if matches(&cfg.browser_patterns, &name_lc) { - Bucket::Browsers - } else if matches(&cfg.terminal_patterns, &name_lc) { - Bucket::Terminals - } else if matches(&cfg.desktop_patterns, &name_lc) { - Bucket::Desktop - } else { - Bucket::Other - }; - - // Protection is decided by the executable NAME alone, independent of the - // bucket: a browser whose cmdline mentions an agent pattern still lands in - // the Agents bucket, but must never lose its protected status. - let protected = matches(&cfg.browser_patterns, &name_lc) - || matches(&cfg.terminal_patterns, &name_lc) - || matches(&cfg.desktop_patterns, &name_lc) - || matches(&cfg.protect_patterns, &name_lc) - || matches(&cfg.protect_patterns, &cmd_lc); - (bucket, protected) -} - -/// Name + start time of one live process, if it exists. Used to fingerprint -/// a pid at lease-registration time. -pub fn identity_of(pid: u32) -> Option<(String, u64)> { - use sysinfo::{Pid, ProcessesToUpdate, System}; - let mut sys = System::new(); - let target = Pid::from_u32(pid); - sys.refresh_processes(ProcessesToUpdate::Some(&[target]), true); - sys.process(target) - .map(|p| (p.name().to_string_lossy().to_string(), p.start_time())) -} - -/// Full audit: classified process table + pressure summary. -pub fn scan(cfg: &Config) -> (HashMap, Pressure) { - use sysinfo::{ProcessesToUpdate, System}; - - let mut sys = System::new_all(); - // Two samples are required for meaningful CPU percentages. - std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); - sys.refresh_cpu_usage(); - sys.refresh_processes(ProcessesToUpdate::All, true); - - let mut procs = HashMap::new(); - for (pid, p) in sys.processes() { - let name = p.name().to_string_lossy().to_string(); - let cmd = p - .cmd() - .iter() - .map(|part| part.to_string_lossy()) - .collect::>() - .join(" "); - let (bucket, protected) = classify(&name, &cmd, cfg); - procs.insert( - pid.as_u32(), - ProcInfo { - pid: pid.as_u32(), - ppid: p.parent().map(|pp| pp.as_u32()), - name, - cmd, - start_time: p.start_time(), - cpu_pct: p.cpu_usage(), - rss_bytes: p.memory(), - bucket, - protected, - }, - ); - } - - let total_mem = sys.total_memory(); - let avail_mem = sys.available_memory(); - let swap_total = sys.total_swap(); - let swap_used = sys.used_swap(); - let cpu = sys.global_cpu_usage(); - let avail_frac = if total_mem > 0 { avail_mem as f64 / total_mem as f64 } else { 1.0 }; - let swap_frac = if swap_total > 0 { swap_used as f64 / swap_total as f64 } else { 0.0 }; - let level = if avail_frac < 0.05 || swap_frac > 0.9 { - "red" - } else if avail_frac < 0.15 || swap_frac > 0.5 || cpu > 90.0 { - "yellow" - } else { - "green" - }; - - let pressure = Pressure { - total_mem_bytes: total_mem, - avail_mem_bytes: avail_mem, - swap_total_bytes: swap_total, - swap_used_bytes: swap_used, - cpu_pct: cpu, - level: level.to_string(), - }; - (procs, pressure) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn cfg() -> Config { - Config::default() - } - - #[test] - fn browsers_are_protected() { - let (bucket, protected) = classify("chrome.exe", "chrome.exe", &cfg()); - assert_eq!(bucket, Bucket::Browsers); - assert!(protected); - } - - #[test] - fn terminals_are_protected() { - let (bucket, protected) = classify("WindowsTerminal.exe", "wt", &cfg()); - assert_eq!(bucket, Bucket::Terminals); - assert!(protected); - } - - #[test] - fn agents_are_classified_but_not_protected() { - let (bucket, protected) = classify("claude.exe", "claude", &cfg()); - assert_eq!(bucket, Bucket::Agents); - assert!(!protected); - - let (bucket, _) = classify("lean-ctx.exe", "lean-ctx serve", &cfg()); - assert_eq!(bucket, Bucket::Agents); - } - - #[test] - fn mcp_in_cmdline_classifies_as_agent_even_for_generic_exe() { - let (bucket, _) = classify("node.exe", "node dist/mcp-server.js --stdio", &cfg()); - assert_eq!(bucket, Bucket::Agents); - } - - #[test] - fn build_tools_bucket() { - let (bucket, protected) = classify("cargo.exe", "cargo build", &cfg()); - assert_eq!(bucket, Bucket::Build); - assert!(!protected); - } - - #[test] - fn unknown_is_other_and_unprotected() { - let (bucket, protected) = classify("randomthing.exe", "randomthing", &cfg()); - assert_eq!(bucket, Bucket::Other); - assert!(!protected); - } - - #[test] - fn browser_with_agent_cmdline_stays_protected() { - // Regression: agent pattern in the cmdline reclassifies the bucket, - // but a browser executable must remain protected regardless. - let (bucket, protected) = - classify("chrome.exe", "chrome --load-extension mcp-helper", &cfg()); - assert_eq!(bucket, Bucket::Agents); - assert!(protected); - } - - #[test] - fn user_protect_pattern_wins_over_agent_bucket() { - let mut cfg = cfg(); - cfg.protect_patterns.push("my-precious".into()); - let (bucket, protected) = classify("my-precious-agent.exe", "mcp thing", &cfg); - assert_eq!(bucket, Bucket::Agents); - assert!(protected); - } -} diff --git a/crates/flared/src/service.rs b/crates/flared/src/service.rs deleted file mode 100644 index ceb7414e..00000000 --- a/crates/flared/src/service.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! `flared service print` — autostart recipes. v1 prints instructions; it -//! does not install anything. - -use std::path::Path; - -/// Recipe text for the given platform ("windows" | "linux" | "macos"). -fn xml_escape(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">") -} - -pub fn autostart_recipe(platform: &str, exe: &Path) -> String { - let exe = exe.display().to_string(); - let exe_xml = xml_escape(&exe); - match platform { - "windows" => format!( - r#"# Register flared as a logon task (run in an elevated or user shell): -schtasks /Create /TN "flared" /TR "\"{exe}\" serve" /SC ONLOGON /RL LIMITED /F - -# Start it now without waiting for the next logon: -schtasks /Run /TN "flared" - -# Remove: -schtasks /Delete /TN "flared" /F -"# - ), - "linux" => format!( - r#"# Save as ~/.config/systemd/user/flared.service: -[Unit] -Description=flared - AI-agent workload hygiene supervisor - -[Service] -ExecStart="{exe}" serve -Restart=on-failure - -[Install] -WantedBy=default.target - -# Then: -systemctl --user enable --now flared -"# - ), - "macos" => format!( - r#"# Save as ~/Library/LaunchAgents/com.getappz.flared.plist: - - - - - Labelcom.getappz.flared - ProgramArguments - {exe_xml}serve - - RunAtLoad - KeepAlive - - - -# Then: -launchctl load ~/Library/LaunchAgents/com.getappz.flared.plist -"# - ), - other => format!("no autostart recipe for platform '{other}'; run '{exe} serve' manually"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn windows_recipe_uses_task_scheduler() { - let text = autostart_recipe("windows", Path::new("C:/bin/flared.exe")); - assert!(text.contains("schtasks")); - assert!(text.contains("C:/bin/flared.exe")); - } - - #[test] - fn linux_recipe_is_a_systemd_unit() { - let text = autostart_recipe("linux", Path::new("/usr/local/bin/flared")); - assert!(text.contains("[Service]")); - assert!(text.contains("systemctl --user enable")); - } - - #[test] - fn macos_recipe_is_a_launchd_plist() { - let text = autostart_recipe("macos", Path::new("/usr/local/bin/flared")); - assert!(text.contains("launchctl")); - assert!(text.contains("plist")); - } - - #[test] - fn paths_with_spaces_are_quoted() { - let text = autostart_recipe("linux", Path::new("/opt/my tools/flared")); - assert!(text.contains("ExecStart=\"/opt/my tools/flared\" serve")); - let text = autostart_recipe("windows", Path::new("C:/My Tools/flared.exe")); - assert!(text.contains(r#"\"C:/My Tools/flared.exe\" serve"#)); - } - - #[test] - fn plist_xml_escapes_special_characters() { - let text = autostart_recipe("macos", Path::new("/opt/a&b/flared")); - assert!(text.contains("/opt/a&b/flared")); - assert!(!text.contains("/opt/a&b/flared")); - } - - #[test] - fn unknown_platform_gets_manual_fallback() { - let text = autostart_recipe("freebsd", Path::new("/bin/flared")); - assert!(text.contains("no autostart recipe")); - } -}