feat: phase 2 — audit config + update --rollback (closes #63 #64)

Axe 8 (sécurité) :
- am audit : checksums SHA-256 des configs (embarquée, utilisateur,
  locale) stockés dans state.json, croisement avec events.jsonl pour
  dater les changements, détection des modifications manuelles hors de
  am (statut MANUAL), rapport lisible + --json stable (contrat)
- backup automatique avant chaque am update (agent ou --all) : état +
  config utilisateur + répertoires d'installation + shims dans
  <state>/backups/<ts>/ avec manifest.json
- am update --rollback [point] : restaure le dernier backup (ou un id
  précis, préfixe accepté), arrête les agents concernés d'abord,
  événement EventKind::Rollback journalisé
- am update --rollback list : liste les points de restauration
- rétention settings.backups_keep (défaut 5) appliquée après chaque backup
- événements EventKind::Backup / EventKind::Rollback pour l'audit

REPL : commande audit + update --rollback (parse, complétion), help specs,
tip cheat sheet, man pages régénérées (50 pages). Version 0.4.6,
ROADMAP cases cochées, 272 tests verts.
This commit is contained in:
2026-08-18 09:44:16 -04:00
parent 22bc529818
commit d9ffc34156
21 changed files with 971 additions and 33 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.5"
version = "0.4.6"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.5"
version = "0.4.6"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
+2
View File
@@ -83,6 +83,8 @@ plateforme, il compile automatiquement depuis les sources.
| am models | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) |
| am catalog update / add <url> | catalogue distant : rafraîchit l'officiel ou ajoute un catalogue d'équipe |
| am audit | qui a modifié quoi, quand (checksums config + journal) |
| am update --rollback | annule la dernière mise à jour (backup automatique avant chaque update) |
**Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop)
+2 -2
View File
@@ -444,8 +444,8 @@ moins de 2 secondes.
| [#60](https://git.dracodev.net/Projets/agent-manager/issues/60) | ✅ Catalogue distant — am catalog update / am catalog add <url> | M |
| [#61](https://git.dracodev.net/Projets/agent-manager/issues/61) | ✅ am suggest — « un agent pour du Python » (tags + usage réel) | M |
| [#62](https://git.dracodev.net/Projets/agent-manager/issues/62) | am lab — benchmark : même tâche sur N agents | L |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | am update --rollback — backup automatique avant mise à jour | M |
| [#63](https://git.dracodev.net/Projets/agent-manager/issues/63) | ✅ am audit — qui a modifié quoi quand | M |
| [#64](https://git.dracodev.net/Projets/agent-manager/issues/64) | ✅ am update --rollback — backup automatique avant mise à jour | M |
| [#65](https://git.dracodev.net/Projets/agent-manager/issues/65) | ✅ Politiques — pin de version, settings.update_policy | S |
| [#66](https://git.dracodev.net/Projets/agent-manager/issues/66) | Synchronisation git automatique — am sync | L |
| [#67](https://git.dracodev.net/Projets/agent-manager/issues/67) | ✅ Partage de catalogue d'équipe (include par URL) | S |
+1
View File
@@ -23,6 +23,7 @@ settings:
models_prune_days: 30 # un modèle est candidat au prune après N jours sans usage (#73)
catalog_cache_ttl_secs: 3600 # TTL du cache des catalogues distants (#60 #67)
catalog_url: null # URL officielle du catalogue distant (défaut: config.yaml du repo) (#60)
backups_keep: 5 # nombre de backups conservés pour am update --rollback (#64)
# --- Command aliases ----------------------------------------------------------
aliases:
+13
View File
@@ -0,0 +1,13 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am-audit 1 "audit "
.SH NAME
audit \- Audit the configuration: who changed what, when (issue #63)
.SH SYNOPSIS
\fBaudit\fR [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Audit the configuration: who changed what, when (issue #63)
.SH OPTIONS
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
+4 -1
View File
@@ -4,7 +4,7 @@
.SH NAME
update \- Update an installed agent to the latest available version
.SH SYNOPSIS
\fBupdate\fR [\fB\-\-all\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
\fBupdate\fR [\fB\-\-all\fR] [\fB\-\-rollback\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIAGENT\fR]
.SH DESCRIPTION
Update an installed agent to the latest available version
.SH OPTIONS
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
\fB\-\-all\fR
Update every installed managed agent
.TP
\fB\-\-rollback\fR [\fI<ROLLBACK>\fR]
Roll back to a pre\-update backup instead of updating ("latest" or a backup id; "\-\-rollback list" shows them)
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help
.TP
+5 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.5"
.TH am 1 "am 0.4.6"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -93,6 +93,9 @@ Manage command aliases (cc \-> claude\-code)
am\-secret(1)
Manage secrets in the OS keyring (never in plaintext config)
.TP
am\-audit(1)
Audit the configuration: who changed what, when (issue #63)
.TP
am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP
@@ -189,4 +192,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.5
v0.4.6
+426
View File
@@ -0,0 +1,426 @@
//! Pre-update backups (issue #64, axe 8): a snapshot of the install
//! directory, the state database and the user config is taken before every
//! real update, kept under <state>/backups/<timestamp>/, and restored by
//! 'am update --rollback <point>'. Retention is bounded by
//! settings.backups_keep (default 5).
use crate::app::App;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Default number of backups to keep (settings.backups_keep default).
pub const DEFAULT_KEEP: u32 = 5;
/// Manifest of one backup point (JSON file inside the backup directory).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupInfo {
/// Backup id = the directory name (RFC 3339 timestamp).
pub id: String,
/// RFC 3339 creation timestamp.
pub created_at: String,
/// Reason: "update:<agent>" or "update:--all".
pub reason: String,
/// Agents whose install directories were snapshotted (empty = all).
pub agents: Vec<String>,
/// True when the state database was snapshotted.
pub state: bool,
/// True when the user config file was snapshotted.
pub config: bool,
}
impl BackupInfo {
/// Load the manifest of one backup directory.
fn load(dir: &Path) -> Option<BackupInfo> {
let text = std::fs::read_to_string(dir.join("manifest.json")).ok()?;
serde_json::from_str(&text).ok()
}
}
/// Directory holding every backup point. Lives next to the state database
/// so tests and --config overrides stay fully sandboxed.
pub fn backups_dir(app: &App) -> PathBuf {
app.paths
.state_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("backups")
}
/// Take a snapshot before an update. `agents` lists the agents being
/// updated; an empty list means "the whole install directory".
pub fn create_backup(app: &App, reason: &str, agents: &[String]) -> Result<BackupInfo> {
let now = crate::installers::now_rfc3339();
// Directory names must be sortable and filesystem-safe: the RFC 3339
// timestamp with colons replaced by dashes.
let id = now.replace(':', "-").replace('Z', "").replace('+', "_");
let root = backups_dir(app).join(&id);
std::fs::create_dir_all(&root)
.with_context(|| format!("cannot create backup directory {}", root.display()))?;
let mut info = BackupInfo {
id: id.clone(),
created_at: now,
reason: reason.to_string(),
agents: agents.to_vec(),
state: false,
config: false,
};
// 1. State database.
if let Ok(sf) = app.state.load() {
let text = serde_json::to_string_pretty(&sf)?;
std::fs::write(root.join("state.json"), text)
.with_context(|| "cannot snapshot state.json".to_string())?;
info.state = true;
}
// 2. User config file (the active origin when it is a user file).
if let Some(origin) = &app.config_origin {
if origin.is_file() {
std::fs::copy(origin, root.join("config.yaml")).with_context(|| {
format!("cannot snapshot user config {}", origin.display())
})?;
info.config = true;
}
}
// 3. Install directories of the updated agents (or everything).
let install_root = &app.paths.install_dir;
let dest_root = root.join("install");
std::fs::create_dir_all(&dest_root)?;
if agents.is_empty() {
copy_tree(install_root, &dest_root)
.with_context(|| format!("cannot snapshot {}", install_root.display()))?;
} else {
for name in agents {
let entry = app.state.get(name).ok().flatten();
if let Some(e) = entry {
let src = PathBuf::from(&e.install_dir);
if src.is_dir() {
let dest = dest_root.join(&e.name);
copy_tree(&src, &dest).with_context(|| {
format!("cannot snapshot {} for {name}", src.display())
})?;
}
}
// Also snapshot the shim binaries (bin/<name>).
let shim = app.paths.bin_dir.join(name);
if shim.exists() {
copy_tree(&shim, &dest_root.join("bin").join(name))?;
}
}
}
// Write the manifest last: a backup without a manifest is invalid.
let manifest = serde_json::to_string_pretty(&info)?;
std::fs::write(root.join("manifest.json"), manifest)
.with_context(|| "cannot write backup manifest".to_string())?;
prune(app)?;
Ok(info)
}
/// List every backup point, oldest first.
pub fn list_backups(app: &App) -> Vec<BackupInfo> {
let mut out: Vec<BackupInfo> = Vec::new();
if let Ok(entries) = std::fs::read_dir(backups_dir(app)) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(info) = BackupInfo::load(&path) {
out.push(info);
}
}
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
/// Resolve a rollback point: "latest"/empty = most recent backup,
/// otherwise the backup id (prefix matching allowed).
pub fn resolve_point(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let all = list_backups(app);
if all.is_empty() {
bail!("no backup found — nothing to roll back to");
}
match point {
None | Some("latest") => all
.last()
.cloned()
.ok_or_else(|| anyhow!("no backup found")),
Some(p) => {
let exact = all.iter().find(|b| b.id == p);
if let Some(b) = exact {
return Ok(b.clone());
}
let prefix: Vec<BackupInfo> = all
.iter()
.filter(|b| b.id.starts_with(p))
.cloned()
.collect();
match prefix.len() {
0 => bail!("backup '{p}' not found — run 'am update --rollback --list'"),
1 => Ok(prefix[0].clone()),
_ => bail!(
"backup prefix '{p}' is ambiguous ({} matches: {})",
prefix.len(),
prefix
.iter()
.map(|b| b.id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
}
}
/// Restore a backup point over the live install: state, user config and
/// install directories. The agent processes of the restored agents are
/// stopped first (they would hold the old binaries open).
pub fn restore_backup(app: &App, point: Option<&str>) -> Result<BackupInfo> {
let info = resolve_point(app, point)?;
let root = backups_dir(app).join(&info.id);
if !root.join("manifest.json").is_file() {
bail!("backup {} is corrupted (no manifest)", info.id);
}
// Stop the agents being restored (only managed ones we know).
let names: Vec<String> = if info.agents.is_empty() {
app.state
.all()?
.keys()
.cloned()
.collect()
} else {
info.agents.clone()
};
for name in &names {
if let Ok(Some(entry)) = app.state.get(name) {
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log
.info(&format!("stopping {name} (pid {pid}) before rollback"));
let _ = crate::commands::run_cmd::stop(app, name, false, None);
}
}
}
}
// 1. Restore the state database.
if info.state {
let src = root.join("state.json");
if src.is_file() {
std::fs::copy(&src, app.state.path())
.with_context(|| "cannot restore state.json".to_string())?;
}
}
// 2. Restore the user config.
if info.config {
if let Some(origin) = &app.config_origin {
if let Some(parent) = origin.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(root.join("config.yaml"), origin)
.with_context(|| format!("cannot restore {}", origin.display()))?;
}
}
// 3. Restore the install directories.
let src_root = root.join("install");
if src_root.is_dir() {
if info.agents.is_empty() {
if app.paths.install_dir.exists() {
std::fs::remove_dir_all(&app.paths.install_dir)
.with_context(|| "cannot clear the install directory".to_string())?;
}
copy_tree(&src_root, &app.paths.install_dir)?;
} else {
for name in &info.agents {
let src = src_root.join(name);
if !src.exists() {
continue;
}
// Remove the agent's current install dir (known from the
// restored state) and copy the snapshot back.
if let Ok(Some(entry)) = app.state.get(name) {
let cur = PathBuf::from(&entry.install_dir);
if cur.is_dir() {
let _ = std::fs::remove_dir_all(&cur);
}
}
let dest = app.paths.install_dir.join(name);
copy_tree(&src, &dest)?;
}
// Restore the shims.
let bin_src = src_root.join("bin");
if bin_src.is_dir() {
copy_tree(&bin_src, &app.paths.bin_dir)?;
}
}
}
Ok(info)
}
/// Enforce settings.backups_keep: delete the oldest backups beyond the cap.
pub fn prune(app: &App) -> Result<()> {
let keep = app.config.settings.backups_keep.unwrap_or(DEFAULT_KEEP) as usize;
let all = list_backups(app);
if all.len() <= keep {
return Ok(());
}
for old in all.iter().take(all.len() - keep) {
let dir = backups_dir(app).join(&old.id);
if dir.is_dir() {
let _ = std::fs::remove_dir_all(&dir);
}
}
Ok(())
}
/// Recursively copy a directory tree (files and directories only; symlinks
/// are copied as-is when possible).
fn copy_tree(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
let meta = std::fs::metadata(src)?;
if meta.is_file() {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dest)?;
return Ok(());
}
std::fs::create_dir_all(dest)?;
let mut entries: Vec<_> = std::fs::read_dir(src)?
.flatten()
.map(|e| e.path())
.collect();
entries.sort();
for child in entries {
let name = child
.file_name()
.ok_or_else(|| anyhow!("invalid path {}", child.display()))?;
copy_tree(&child, &dest.join(name))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::state::StateFile;
use clap::Parser;
use std::io::Write;
fn test_app(tag: &str) -> App {
let dir = std::env::temp_dir().join(format!("am-backup-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(&cfg, "version: \"1.0\"\nagents: []\n").unwrap();
let cli = Cli::parse_from([
"am",
"--config",
cfg.to_str().unwrap(),
"--yes",
"--no-color",
]);
let mut app = App::from_cli(cli).expect("app builds");
// Point the state/install dirs into the temp dir so nothing touches
// the real user data.
let state_file = dir.join("state.json");
let install = dir.join("agents");
std::fs::create_dir_all(&install).unwrap();
app.paths.state_file = state_file.clone();
app.paths.install_dir = install.clone();
app.paths.bin_dir = install.join("bin");
app.state = crate::state::StateStore::new(state_file);
app.config_origin = Some(cfg);
// Fresh backups dir for this test run.
let _ = std::fs::remove_dir_all(backups_dir(&app));
app
}
fn write_agent_file(app: &App, name: &str, content: &str) -> PathBuf {
let dir = app.paths.install_dir.join(name);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("agent.txt");
let mut h = std::fs::File::create(&f).unwrap();
h.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn backup_create_list_restore_roundtrip() {
let app = test_app("roundtrip");
write_agent_file(&app, "alpha", "v1");
write_agent_file(&app, "beta", "b1");
// Simulate a managed entry for alpha.
let mut sf = StateFile::default();
sf.installed.insert(
"alpha".to_string(),
crate::state::InstalledEntry {
name: "alpha".to_string(),
version: Some("1.0.0".to_string()),
method: "binary".to_string(),
install_dir: app.paths.install_dir.join("alpha").display().to_string(),
bins: vec![],
run: "alpha".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
},
);
app.state.save(&sf).unwrap();
let info = create_backup(&app, "update:alpha", &["alpha".to_string()]).unwrap();
assert!(info.state && info.config);
// Corrupt the live install + state.
write_agent_file(&app, "alpha", "BROKEN");
let mut bad = StateFile::default();
bad.sessions_count = 999;
app.state.save(&bad).unwrap();
restore_backup(&app, Some(&info.id)).unwrap();
let text = std::fs::read_to_string(app.paths.install_dir.join("alpha/agent.txt")).unwrap();
assert_eq!(text, "v1", "install dir restored");
let sf = app.state.load().unwrap();
assert_eq!(sf.installed.get("alpha").unwrap().version.as_deref(), Some("1.0.0"));
assert_eq!(sf.sessions_count, 0, "state restored");
}
#[test]
fn resolve_point_prefix_and_latest() {
let app = test_app("resolve");
create_backup(&app, "update:a", &[]).unwrap();
let info = resolve_point(&app, None).unwrap();
assert_eq!(info.agents, Vec::<String>::new());
let prefix = &info.id[..info.id.len().min(10)];
let by_prefix = resolve_point(&app, Some(prefix)).unwrap();
assert_eq!(by_prefix.id, info.id);
}
#[test]
fn prune_keeps_only_backups_keep() {
let mut app = test_app("prune");
app.config.settings.backups_keep = Some(1);
create_backup(&app, "update:one", &[]).unwrap();
create_backup(&app, "update:two", &[]).unwrap();
let all = list_backups(&app);
assert_eq!(all.len(), 1, "only the newest backup survives");
}
}
+6
View File
@@ -175,6 +175,8 @@ pub enum Command {
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// Audit the configuration: who changed what, when (issue #63)
Audit,
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs),
/// Manage remote catalogs: update the official one, add external ones
@@ -320,6 +322,10 @@ pub enum Command {
/// Update every installed managed agent
#[arg(long)]
all: bool,
/// Roll back to a pre-update backup instead of updating
/// ("latest" or a backup id; "--rollback list" shows them)
#[arg(long, num_args = 0..=1, default_missing_value = "latest")]
rollback: Option<String>,
},
/// Search the catalog by keyword (name, description, category, tags)
Search {
+332
View File
@@ -0,0 +1,332 @@
//! 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<u64> {
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<u64>)> {
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<String>,
via: Option<String>, // "am <kind>" or "manual (outside am)"
}
/// One audited journal event.
#[derive(serde::Serialize)]
struct EventRow {
when: String,
who: String, // "am <kind>"
what: String,
agent: Option<String>,
}
pub fn run(app: &App) -> Result<i32> {
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<FileRow> = Vec::new();
let mut next_baseline: BTreeMap<String, ConfigChecksum> = 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, &current, 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<EventRow> = 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);
}
}
+5 -1
View File
@@ -3,6 +3,7 @@
pub mod agents_cmd;
pub mod alias_cmd;
pub mod annotations_cmd;
pub mod audit_cmd;
pub mod catalog_cmd;
pub mod completion_cmd;
pub mod config_cmd;
@@ -143,7 +144,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
force,
} => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Update { agent, all, rollback } => {
update_cmd::run(app, agent.as_deref(), *all, rollback.as_deref())
}
Command::Audit => audit_cmd::run(app),
Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref())
}
+15
View File
@@ -119,6 +119,21 @@ pub static SECTIONS: &[TipSection] = &[
options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml",
},
TipEntry {
usage: "audit",
about: "qui a modifié quoi, quand (checksums config + journal d'événements)",
options: &[("--json", "rapport machine-readable")],
example: "audit --json",
},
TipEntry {
usage: "update --rollback",
about: "annule la dernière mise à jour (backup automatique avant chaque update)",
options: &[
("--rollback list", "liste les points de restauration"),
("--rollback <id>", "restaure un point précis"),
],
example: "update --rollback list",
},
TipEntry {
usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
+71 -1
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists.
use super::*;
use crate::backup;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
@@ -9,7 +10,17 @@ use anyhow::{bail, Result};
use std::cmp::Ordering;
use std::path::PathBuf;
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
pub fn run(
app: &App,
agent_name: Option<&str>,
all: bool,
rollback: Option<&str>,
) -> Result<i32> {
// Issue #64: --rollback restores a pre-update backup instead of updating.
if rollback.is_some() {
return rollback_point(app, rollback);
}
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
let policy = app.config.settings.update_policy.unwrap_or_default();
@@ -28,6 +39,16 @@ pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
// Issue #64: one snapshot covers the whole batch.
if !app.dry_run() && !names.is_empty() {
let info = backup::create_backup(app, "update:--all", &names)?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_reason("update:--all"),
);
}
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
@@ -124,6 +145,16 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
// Issue #64: snapshot before the real update so a broken upgrade can be
// undone with 'am update --rollback'.
let info = backup::create_backup(app, &format!("update:{}", agent.name), &[agent.name.clone()])?;
app.log.verbose(&format!("pre-update backup: {}", info.id));
app.emit(
&Event::now(EventKind::Backup)
.with_args(vec![info.id.clone()])
.with_agent(agent.name.clone())
.with_reason("pre-update"),
);
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
@@ -147,3 +178,42 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
));
Ok(0)
}
/// Issue #64: 'am update --rollback [point]' — restore a pre-update backup.
/// '--rollback list' prints the available backup points.
fn rollback_point(app: &App, point: Option<&str>) -> Result<i32> {
if point == Some("list") {
let all = backup::list_backups(app);
if all.is_empty() {
app.log.info("no backup found — run an update first to create one");
return Ok(0);
}
println!("pre-update backups ({}):", all.len());
for b in &all {
println!(
" {} {} agents={}{}",
b.id,
b.reason,
if b.agents.is_empty() {
"all".to_string()
} else {
b.agents.join(",")
},
if b.state && b.config { " [state+config]" } else { "" }
);
}
println!("\nuse 'am update --rollback <id>' to restore one");
return Ok(0);
}
let info = backup::restore_backup(app, point)?;
app.emit(
&Event::now(EventKind::Rollback)
.with_args(vec![info.id.clone()])
.with_reason(&info.reason),
);
app.log.success(&format!(
"rolled back to backup {} ({})",
info.id, info.reason
));
Ok(0)
}
+7
View File
@@ -117,6 +117,10 @@ pub struct Settings {
/// is attempted (issue #60, default 3600).
#[serde(default)]
pub catalog_cache_ttl_secs: Option<u64>,
/// How many pre-update backups 'am update --rollback' keeps
/// (issue #64, default 5).
#[serde(default)]
pub backups_keep: Option<u32>,
}
/// Policy controlling how aggressively 'am update' upgrades installed agents.
@@ -638,6 +642,9 @@ pub fn merge(base: &mut Config, overlay: Config) {
if o.catalog_cache_ttl_secs.is_some() {
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs;
}
if o.backups_keep.is_some() {
s.backups_keep = o.backups_keep;
}
for (k, v) in o.hooks {
s.hooks.insert(k, v);
}
+6
View File
@@ -36,6 +36,10 @@ pub enum EventKind {
Model,
/// Remote catalog action: update or add of an external catalog (issue #60).
Catalog,
/// Pre-update backup created (issue #64).
Backup,
/// Rollback of a previous backup (issue #64).
Rollback,
}
impl EventKind {
@@ -55,6 +59,8 @@ impl EventKind {
EventKind::Annotate => "annotate",
EventKind::Model => "model",
EventKind::Catalog => "catalog",
EventKind::Backup => "backup",
EventKind::Rollback => "rollback",
}
}
}
+36 -19
View File
@@ -421,6 +421,42 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Machine-readable output.", code: "suggest frontend framework --json" },
],
},
HelpSpec {
name: "audit",
category: "Commands",
usage: "audit {flags}",
about: "Who changed what, when: config checksums vs the event journal, manual edits flagged.",
search_terms: &["security", "trace", "checksum", "who changed", "history config"],
flags: &[
HelpFlag { short: "", long: "--json", value: "", desc: "Stable machine-readable report" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Run the config audit.", code: "audit" },
HelpExample { desc: "Machine-readable report.", code: "audit --json" },
],
},
HelpSpec {
name: "update",
category: "Commands",
usage: "update {flags} <agent|--all>",
about: "Update installed agents; every update is backed up first and can be undone.",
search_terms: &["upgrade", "rollback", "restore", "backup"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Update every managed agent (one backup for the batch)" },
HelpFlag { short: "", long: "--rollback", value: "[POINT]", desc: "Restore a pre-update backup ('list' shows them, default: latest)" },
],
subcommands: &[],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Update one agent (backup taken first).", code: "update claude-code" },
HelpExample { desc: "List the available backups.", code: "update --rollback list" },
HelpExample { desc: "Undo the last update.", code: "update --rollback" },
],
},
HelpSpec {
name: "alias",
category: "Commands",
@@ -574,25 +610,6 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "Remove everything, including logs and the config entry.", code: "uninstall claude-code --purge" },
],
},
HelpSpec {
name: "update",
category: "Commands",
usage: "update {flags} [agent]",
about: "Update an installed agent to the latest available version.",
search_terms: &["upgrade"],
flags: &[
HelpFlag { short: "", long: "--all", value: "", desc: "Update every installed managed agent" },
],
subcommands: &[],
parameters: &[
HelpParam { name: "agent", typ: "string", desc: "Agent name or alias (required unless --all)" },
],
io: None,
examples: &[
HelpExample { desc: "Update one agent.", code: "update claude-code" },
HelpExample { desc: "Update every managed agent.", code: "update --all" },
],
},
HelpSpec {
name: "start",
category: "Commands",
+1
View File
@@ -12,6 +12,7 @@
//! * commands — one module per CLI command.
pub mod app;
pub mod backup;
pub mod catalog;
pub mod catalog_remote;
pub mod cli;
+16 -2
View File
@@ -109,6 +109,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("models", "list local models (ollama, llama.cpp, LM Studio)"),
("catalog", "manage remote catalogs"),
("suggest", "recommend an agent for a request"),
("audit", "who changed what, when (config checksums)"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"),
@@ -222,7 +223,7 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man", "models", "catalog", "suggest",
"profile", "man", "models", "catalog", "suggest", "audit",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
@@ -769,7 +770,7 @@ pub fn banner_box(
" config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
));
rows.push(inner(
" models models · models --prune · catalog · suggest".to_string(),
" models models · models --prune · catalog · suggest · audit".to_string(),
));
rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(),
@@ -1608,14 +1609,23 @@ fn handle_line(
Command::Update {
agent: None,
all: true,
rollback: opt_value("--rollback"),
}
} else if let Some(rb) = opt_value("--rollback") {
Command::Update {
agent: None,
all: false,
rollback: Some(rb),
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
rollback: None,
}
}
}
"audit" => Command::Audit,
"doctor" => Command::Doctor { fix: flag("--fix") },
"run" => {
let agent = need("agent name")?;
@@ -1730,6 +1740,10 @@ fn is_am_command(word: &str) -> bool {
| "profile"
| "man"
| "tip"
| "models"
| "catalog"
| "suggest"
| "audit"
)
}
+16
View File
@@ -24,6 +24,21 @@ pub struct StateFile {
/// Personal annotations per agent: favorite, note, tags (issue #39).
#[serde(default)]
pub annotations: BTreeMap<String, Annotation>,
/// Config file checksums used by 'am audit' (axe 8, issue #63).
#[serde(default)]
pub config_checksums: BTreeMap<String, ConfigChecksum>,
}
/// One recorded config checksum: the baseline 'am audit' compares against
/// to detect manual modifications (issue #63).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigChecksum {
/// SHA-256 of the file content (hex).
pub sha256: String,
/// File modification time (seconds since epoch) at recording time.
pub mtime_secs: u64,
/// RFC 3339 timestamp of the recording.
pub recorded_at: String,
}
/// Personal annotations attached to an agent by the user (issue #39).
@@ -48,6 +63,7 @@ impl Default for StateFile {
sessions_count: 0,
last_used: None,
annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(),
}
}
}
+5 -3
View File
@@ -1,6 +1,7 @@
use agent_manager::commands::update_cmd;
use agent_manager::config::UpdatePolicy;
use agent_manager::state::{InstalledEntry, StateFile};
use clap::Parser;
use std::collections::BTreeMap;
mod common;
@@ -26,6 +27,7 @@ fn installed_state(name: &str, version: &str) -> StateFile {
sessions_count: 0,
last_used: None,
annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(),
}
}
@@ -44,7 +46,7 @@ fn update_policy_none_blocks_all() {
let mut app = common::test_app("policy_none", &["--yes"]);
app.config.settings.update_policy = Some(UpdatePolicy::None);
app.state.save(&installed_state("pi", "0.1.0")).unwrap();
let code = update_cmd::run(&app, None, true).unwrap();
let code = update_cmd::run(&app, None, true, None).unwrap();
assert_eq!(code, 0);
}
@@ -53,7 +55,7 @@ fn pinned_agent_is_skipped() {
let body = agent_yaml("pi", Some("0.1.0"));
let app = common::test_app("pinned_ok", &[]);
app.state.save(&installed_state("pi", "0.1.0")).unwrap();
let code = update_cmd::run(&app, Some("pi"), false).unwrap();
let code = update_cmd::run(&app, Some("pi"), false, None).unwrap();
assert_eq!(code, 0);
}
@@ -62,6 +64,6 @@ fn pinned_agent_mismatch_warns() {
let body = agent_yaml("pi", Some("0.1.0"));
let app = common::test_app("pinned_warn", &[]);
app.state.save(&installed_state("pi", "0.2.0")).unwrap();
let code = update_cmd::run(&app, Some("pi"), false).unwrap();
let code = update_cmd::run(&app, Some("pi"), false, None).unwrap();
assert_eq!(code, 0);
}