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]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.4.5" version = "0.4.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.4.5" version = "0.4.6"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" 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 | inventaire des modèles locaux (ollama, llama.cpp, LM Studio) |
| am models --prune | purge des modèles inutilisés (--dry-run : simulation) | | 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 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é **Statuts** : 🟢 running · 🔵 installed (géré par am) · 🟡 external (trouvé
sur le PATH) · ⚪ not-installed · 🔴 not-installable (SaaS/desktop) 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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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 | | [#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) 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_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) 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 ---------------------------------------------------------- # --- Command aliases ----------------------------------------------------------
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 .SH NAME
update \- Update an installed agent to the latest available version update \- Update an installed agent to the latest available version
.SH SYNOPSIS .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 .SH DESCRIPTION
Update an installed agent to the latest available version Update an installed agent to the latest available version
.SH OPTIONS .SH OPTIONS
@@ -12,6 +12,9 @@ Update an installed agent to the latest available version
\fB\-\-all\fR \fB\-\-all\fR
Update every installed managed agent Update every installed managed agent
.TP .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 \fB\-h\fR, \fB\-\-help\fR
Print help Print help
.TP .TP
+5 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 0.4.5" .TH am 1 "am 0.4.6"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -93,6 +93,9 @@ Manage command aliases (cc \-> claude\-code)
am\-secret(1) am\-secret(1)
Manage secrets in the OS keyring (never in plaintext config) Manage secrets in the OS keyring (never in plaintext config)
.TP .TP
am\-audit(1)
Audit the configuration: who changed what, when (issue #63)
.TP
am\-models(1) am\-models(1)
List local models (ollama, llama.cpp, LM Studio) and prune unused ones List local models (ollama, llama.cpp, LM Studio) and prune unused ones
.TP .TP
@@ -189,4 +192,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .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) /// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)] #[command(subcommand)]
Secret(SecretCmd), Secret(SecretCmd),
/// Audit the configuration: who changed what, when (issue #63)
Audit,
/// List local models (ollama, llama.cpp, LM Studio) and prune unused ones /// List local models (ollama, llama.cpp, LM Studio) and prune unused ones
Models(ModelsArgs), Models(ModelsArgs),
/// Manage remote catalogs: update the official one, add external ones /// Manage remote catalogs: update the official one, add external ones
@@ -320,6 +322,10 @@ pub enum Command {
/// Update every installed managed agent /// Update every installed managed agent
#[arg(long)] #[arg(long)]
all: bool, 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 the catalog by keyword (name, description, category, tags)
Search { 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 agents_cmd;
pub mod alias_cmd; pub mod alias_cmd;
pub mod annotations_cmd; pub mod annotations_cmd;
pub mod audit_cmd;
pub mod catalog_cmd; pub mod catalog_cmd;
pub mod completion_cmd; pub mod completion_cmd;
pub mod config_cmd; pub mod config_cmd;
@@ -143,7 +144,10 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
force, force,
} => install_cmd::run(app, agent, method.as_deref(), *force), } => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge), 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 } => { Command::Search { keyword, category, tag } => {
search_cmd::run(app, keyword.as_deref(), category.as_deref(), tag.as_deref()) 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")], options: &[("add <url>", "ajoute un catalogue d'équipe par URL")],
example: "catalog add https://git.dracodev.net/team/agents.yaml", 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 { TipEntry {
usage: "install <agent>", usage: "install <agent>",
about: "installe l'agent et ses dépendances (Node, Python, Rust…)", about: "installe l'agent et ses dépendances (Node, Python, Rust…)",
+71 -1
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists. //! reinstall when an update exists.
use super::*; use super::*;
use crate::backup;
use crate::events::{Event, EventKind}; use crate::events::{Event, EventKind};
use crate::installers; use crate::installers;
use crate::runner::{Runner, SystemRunner}; use crate::runner::{Runner, SystemRunner};
@@ -9,7 +10,17 @@ use anyhow::{bail, Result};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::path::PathBuf; 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 runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
let policy = app.config.settings.update_policy.unwrap_or_default(); 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 entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect(); let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0; 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 { for name in names {
if let Err(e) = update_one(app, &runner, &name) { if let Err(e) = update_one(app, &runner, &name) {
failed += 1; 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)); .dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0); 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)?; crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?; let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app); 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) 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). /// is attempted (issue #60, default 3600).
#[serde(default)] #[serde(default)]
pub catalog_cache_ttl_secs: Option<u64>, 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. /// 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() { if o.catalog_cache_ttl_secs.is_some() {
s.catalog_cache_ttl_secs = o.catalog_cache_ttl_secs; 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 { for (k, v) in o.hooks {
s.hooks.insert(k, v); s.hooks.insert(k, v);
} }
+6
View File
@@ -36,6 +36,10 @@ pub enum EventKind {
Model, Model,
/// Remote catalog action: update or add of an external catalog (issue #60). /// Remote catalog action: update or add of an external catalog (issue #60).
Catalog, Catalog,
/// Pre-update backup created (issue #64).
Backup,
/// Rollback of a previous backup (issue #64).
Rollback,
} }
impl EventKind { impl EventKind {
@@ -55,6 +59,8 @@ impl EventKind {
EventKind::Annotate => "annotate", EventKind::Annotate => "annotate",
EventKind::Model => "model", EventKind::Model => "model",
EventKind::Catalog => "catalog", 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" }, 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 { HelpSpec {
name: "alias", name: "alias",
category: "Commands", 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" }, 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 { HelpSpec {
name: "start", name: "start",
category: "Commands", category: "Commands",
+1
View File
@@ -12,6 +12,7 @@
//! * commands — one module per CLI command. //! * commands — one module per CLI command.
pub mod app; pub mod app;
pub mod backup;
pub mod catalog; pub mod catalog;
pub mod catalog_remote; pub mod catalog_remote;
pub mod cli; 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)"), ("models", "list local models (ollama, llama.cpp, LM Studio)"),
("catalog", "manage remote catalogs"), ("catalog", "manage remote catalogs"),
("suggest", "recommend an agent for a request"), ("suggest", "recommend an agent for a request"),
("audit", "who changed what, when (config checksums)"),
("shell", "show or switch the system shell"), ("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"), ("theme", "show or switch the color theme"),
("tip", "cheat sheet of the most useful commands"), ("tip", "cheat sheet of the most useful commands"),
@@ -222,7 +223,7 @@ impl AmCompleter {
"start", "stop", "restart", "run", "doctor", "config", "completion", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", "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", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
], ],
config_sub: vec!["show", "path", "edit", "validate", "add"], 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(), " config alias · secret · profile · config · doctor · completion · man · tip".to_string(),
)); ));
rows.push(inner( rows.push(inner(
" models models · models --prune · catalog · suggest".to_string(), " models models · models --prune · catalog · suggest · audit".to_string(),
)); ));
rows.push(inner( rows.push(inner(
" system self-update · self-uninstall · export · import".to_string(), " system self-update · self-uninstall · export · import".to_string(),
@@ -1608,14 +1609,23 @@ fn handle_line(
Command::Update { Command::Update {
agent: None, agent: None,
all: true, all: true,
rollback: opt_value("--rollback"),
}
} else if let Some(rb) = opt_value("--rollback") {
Command::Update {
agent: None,
all: false,
rollback: Some(rb),
} }
} else { } else {
Command::Update { Command::Update {
agent: rest.first().cloned(), agent: rest.first().cloned(),
all: false, all: false,
rollback: None,
} }
} }
} }
"audit" => Command::Audit,
"doctor" => Command::Doctor { fix: flag("--fix") }, "doctor" => Command::Doctor { fix: flag("--fix") },
"run" => { "run" => {
let agent = need("agent name")?; let agent = need("agent name")?;
@@ -1730,6 +1740,10 @@ fn is_am_command(word: &str) -> bool {
| "profile" | "profile"
| "man" | "man"
| "tip" | "tip"
| "models"
| "catalog"
| "suggest"
| "audit"
) )
} }
+16
View File
@@ -24,6 +24,21 @@ pub struct StateFile {
/// Personal annotations per agent: favorite, note, tags (issue #39). /// Personal annotations per agent: favorite, note, tags (issue #39).
#[serde(default)] #[serde(default)]
pub annotations: BTreeMap<String, Annotation>, 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). /// Personal annotations attached to an agent by the user (issue #39).
@@ -48,6 +63,7 @@ impl Default for StateFile {
sessions_count: 0, sessions_count: 0,
last_used: None, last_used: None,
annotations: BTreeMap::new(), annotations: BTreeMap::new(),
config_checksums: BTreeMap::new(),
} }
} }
} }
+5 -3
View File
@@ -1,6 +1,7 @@
use agent_manager::commands::update_cmd; use agent_manager::commands::update_cmd;
use agent_manager::config::UpdatePolicy; use agent_manager::config::UpdatePolicy;
use agent_manager::state::{InstalledEntry, StateFile}; use agent_manager::state::{InstalledEntry, StateFile};
use clap::Parser;
use std::collections::BTreeMap; use std::collections::BTreeMap;
mod common; mod common;
@@ -26,6 +27,7 @@ fn installed_state(name: &str, version: &str) -> StateFile {
sessions_count: 0, sessions_count: 0,
last_used: None, last_used: None,
annotations: BTreeMap::new(), 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"]); let mut app = common::test_app("policy_none", &["--yes"]);
app.config.settings.update_policy = Some(UpdatePolicy::None); app.config.settings.update_policy = Some(UpdatePolicy::None);
app.state.save(&installed_state("pi", "0.1.0")).unwrap(); 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); assert_eq!(code, 0);
} }
@@ -53,7 +55,7 @@ fn pinned_agent_is_skipped() {
let body = agent_yaml("pi", Some("0.1.0")); let body = agent_yaml("pi", Some("0.1.0"));
let app = common::test_app("pinned_ok", &[]); let app = common::test_app("pinned_ok", &[]);
app.state.save(&installed_state("pi", "0.1.0")).unwrap(); 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); assert_eq!(code, 0);
} }
@@ -62,6 +64,6 @@ fn pinned_agent_mismatch_warns() {
let body = agent_yaml("pi", Some("0.1.0")); let body = agent_yaml("pi", Some("0.1.0"));
let app = common::test_app("pinned_warn", &[]); let app = common::test_app("pinned_warn", &[]);
app.state.save(&installed_state("pi", "0.2.0")).unwrap(); 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); assert_eq!(code, 0);
} }