//! audit: who changed what, when (issue #63, axe 8). Config checksums are //! recorded in state.json; 'am audit' compares them with the current files, //! cross-references the event journal to date the changes, and flags edits //! made outside of agent-manager. use super::*; use crate::events::{Event, EventKind}; use crate::output::print_json; use crate::state::ConfigChecksum; use anyhow::Result; use std::collections::BTreeMap; use std::path::PathBuf; /// Labels of the tracked config files. const EMBEDDED: &str = "embedded"; const USER: &str = "user"; const LOCAL: &str = "local"; /// SHA-256 (hex) of a file's content, plus its mtime. fn checksum_of(text: &str, mtime_secs: u64) -> ConfigChecksum { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(text.as_bytes()); ConfigChecksum { sha256: format!("{:x}", h.finalize()), mtime_secs, recorded_at: crate::installers::now_rfc3339(), } } /// mtime in seconds since epoch. fn mtime_secs(path: &std::path::Path) -> Option { std::fs::metadata(path) .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) } /// The tracked config files: label -> (display path, content). fn tracked_files(app: &App) -> Vec<(&'static str, String, Option)> { let mut out = Vec::new(); // Embedded default catalog: content is baked into the binary. out.push((EMBEDDED, crate::config::DEFAULT_CONFIG.to_string(), None)); // User config file. if let Some(dir) = &app.paths.config_dir { let p = dir.join(crate::config::CONFIG_FILE_NAME); if let Ok(text) = std::fs::read_to_string(&p) { out.push((USER, text, mtime_secs(&p))); } } // Local project config (agent-manager.yaml in the current directory). let local = PathBuf::from(crate::config::CONFIG_FILE_NAME); if let Ok(text) = std::fs::read_to_string(&local) { out.push((LOCAL, text, mtime_secs(&local))); } out } /// Kinds of events that represent a modification of the configuration or /// the fleet (they explain a checksum change). fn is_mutating_kind(k: &EventKind) -> bool { matches!( k, EventKind::Config | EventKind::Install | EventKind::Update | EventKind::Uninstall | EventKind::Annotate | EventKind::Catalog | EventKind::Model | EventKind::Backup | EventKind::Rollback ) } /// One audit row: a tracked file and its verdict. #[derive(serde::Serialize)] struct FileRow { file: String, label: &'static str, status: &'static str, // ok | new | changed | deleted changed_at: Option, via: Option, // "am " or "manual (outside am)" } /// One audited journal event. #[derive(serde::Serialize)] struct EventRow { when: String, who: String, // "am " what: String, agent: Option, } pub fn run(app: &App) -> Result { let mut sf = app.state.load()?; let baseline = std::mem::take(&mut sf.config_checksums); // 1. Current checksums of every tracked file. let files = tracked_files(app); let mut rows: Vec = Vec::new(); let mut next_baseline: BTreeMap = BTreeMap::new(); for (label, text, mtime) in &files { let current = checksum_of(text, mtime.unwrap_or(0)); let (status, via, changed_at) = match baseline.get(*label) { None => ("new", Some("baseline recorded now".to_string()), None), Some(prev) if prev.sha256 == current.sha256 => ("ok", None, None), Some(prev) => { let at = mtime .and_then(|t| { chrono::DateTime::from_timestamp(t as i64, 0) .map(|d| d.to_rfc3339()) }) .unwrap_or_else(|| "unknown".to_string()); (status_changed(prev, ¤t, app, &at), Some(at.clone()), Some(at)) } }; let display = match *label { EMBEDDED => "config.yaml (embedded default)".to_string(), USER => user_config_display(app), LOCAL => format!("./{} (project)", crate::config::CONFIG_FILE_NAME), _ => label.to_string(), }; rows.push(FileRow { file: display, label, status, changed_at, via, }); next_baseline.insert(label.to_string(), current); } // 2. Cross-reference the journal: recent mutating events. let events = crate::events::read_events(&app.events_dir(), 200); let event_rows: Vec = events .iter() .filter(|e| is_mutating_kind(&e.kind)) .map(|e| EventRow { when: e.ts.clone(), who: format!("am {}", e.kind.as_str()), what: format!( "{}{}", e.reason.clone().unwrap_or_default(), if e.args.is_empty() { String::new() } else { format!(" ({})", e.args.join(", ")) } ), agent: e.agent.clone(), }) .collect(); // 3. Persist the new baseline so the next audit compares against now. sf.config_checksums = next_baseline; app.state.save(&sf)?; if app.json() { print_json(&serde_json::json!({ "files": rows, "events": event_rows, "generated_at": crate::installers::now_rfc3339(), })); return Ok(0); } // 4. Human-readable report. println!("config audit — who changed what, when\n"); let mut header = crate::tables::DataTable { columns: vec!["FILE".into(), "STATUS".into(), "WHEN".into(), "VIA".into()], rows: Vec::new(), }; for r in &rows { header.rows.push(vec![ crate::tables::Cell::str(&r.file), crate::tables::Cell::str(status_style(r.status)), crate::tables::Cell::str(r.changed_at.as_deref().unwrap_or("-")), crate::tables::Cell::str(r.via.as_deref().unwrap_or("-")), ]); } let width = crate::output::terminal_width().unwrap_or(120); print!("{}", header.render_themed(app.theme(), app.color(), width)); println!("\nrecent changes (event journal):"); if event_rows.is_empty() { println!(" none"); } else { for e in event_rows.iter().take(15) { println!( " {} {} {}{}", e.when, e.who, e.agent.as_deref().unwrap_or("-"), if e.what.is_empty() { String::new() } else { format!(" — {}", e.what) } ); } } let changed = rows.iter().filter(|r| r.status == "changed").count(); if changed == 0 { app.log.success("no configuration change detected since the last audit"); } else { app.log .warn(&format!("{changed} configuration file(s) changed since the last audit")); } Ok(0) } fn status_changed( prev: &ConfigChecksum, current: &ConfigChecksum, app: &App, at: &str, ) -> &'static str { // A change can be explained by am itself (an event after the baseline). let events = crate::events::read_events(&app.events_dir(), 100); let explained = events.iter().any(|e| { is_mutating_kind(&e.kind) && e.ts.as_str() >= prev.recorded_at.as_str() }); // 'via' is filled by the caller from this: manual vs am. let _ = (current, at); if explained { "changed" } else { "manual" } } fn status_style(s: &str) -> String { match s { "ok" => "ok".to_string(), "new" => "new".to_string(), "changed" => "CHANGED".to_string(), "manual" => "MANUAL".to_string(), _ => s.to_string(), } } fn user_config_display(app: &App) -> String { app.paths .config_dir .as_ref() .map(|d| d.join(crate::config::CONFIG_FILE_NAME).display().to_string()) .unwrap_or_else(|| crate::config::CONFIG_FILE_NAME.to_string()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::cli::Cli; use crate::state::{StateFile, StateStore}; use clap::Parser; use std::io::Write; fn test_app(tag: &str) -> (App, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let cfg = dir.path().join("config.yaml"); std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap(); let cli = Cli::parse_from([ "am", "--config", cfg.to_str().unwrap(), "--no-color", ]); let mut app = App::from_cli(cli).expect("app builds"); let state_file = dir.path().join("state.json"); app.paths.state_file = state_file.clone(); app.paths.config_dir = Some(dir.path().to_path_buf()); app.state = StateStore::new(state_file); (app, dir) } #[test] fn checksum_changes_when_content_changes() { let a = checksum_of("version: \"1.0\"\n", 0); let b = checksum_of("version: \"1.0\"\n# extra\n", 0); assert_ne!(a.sha256, b.sha256, "different content -> different hash"); assert_eq!(a.sha256, checksum_of("version: \"1.0\"\n", 0).sha256); } #[test] fn audit_records_baseline_and_detects_change() { let (app, dir) = test_app("audit"); let user_cfg = dir.path().join("config.yaml"); // First run: everything is recorded as baseline. run(&app).unwrap(); let sf = app.state.load().unwrap(); assert!(sf.config_checksums.contains_key(EMBEDDED)); assert!(sf.config_checksums.contains_key(USER)); // Touch the user config outside of am. let mut f = std::fs::OpenOptions::new() .append(true) .open(&user_cfg) .unwrap(); writeln!(f, "# modified by hand").unwrap(); drop(f); // Second run: the change is flagged (status != ok). run(&app).unwrap(); let sf2 = app.state.load().unwrap(); // The baseline was re-recorded, so a third run is clean. run(&app).unwrap(); let sf3 = app.state.load().unwrap(); assert_eq!(sf3.config_checksums.len(), sf2.config_checksums.len()); assert!(sf3.config_checksums[USER].sha256.len() == 64); } #[test] fn state_file_roundtrips_checksums() { let mut sf = StateFile::default(); sf.config_checksums.insert( "user".to_string(), ConfigChecksum { sha256: "ab".repeat(32), mtime_secs: 42, recorded_at: "2026-08-18T00:00:00Z".to_string(), }, ); let text = serde_json::to_string(&sf).unwrap(); let back: StateFile = serde_json::from_str(&text).unwrap(); assert_eq!(back.config_checksums["user"].mtime_secs, 42); } }