diff --git a/src/cli.rs b/src/cli.rs index 3978da4..3d9703e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -83,6 +83,10 @@ pub struct StatusArgs { /// With --remote: skip fetching trace counts for each ref (faster) #[arg(long)] pub no_fetch: bool, + + /// With --remote: only show developers active within this window (e.g. 7, 7d, 48h) + #[arg(long, value_name = "DURATION")] + pub since: Option, } #[derive(Args, Debug)] @@ -312,8 +316,15 @@ pub enum KeysAction { Init, /// Register the local public key in the git key registry (refs/agentdiff/keys/) Register, - /// Rotate the local keypair: back up the old keys, generate new ones, and register them - Rotate, + /// Rotate the local keypair: archive the old keys, generate new ones, and register them + Rotate(RotateKeysArgs), +} + +#[derive(Args, Debug, Default)] +pub struct RotateKeysArgs { + /// Re-sign the last N entries in the current branch's local trace buffer with the new key + #[arg(long)] + pub resign_last: Option, } // ── Verify ──────────────────────────────────────────────────────────────────── diff --git a/src/commands/keys.rs b/src/commands/keys.rs index 1e7e7be..96215c4 100644 --- a/src/commands/keys.rs +++ b/src/commands/keys.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use colored::Colorize; +use crate::cli::RotateKeysArgs; use crate::keys; use crate::store; use crate::util::ok; @@ -44,24 +45,19 @@ pub fn run_register(store: &crate::store::Store) -> Result<()> { Ok(()) } -/// Rotate the local keypair: back up existing keys, generate new ones, register in registry. -pub fn run_rotate(store: &crate::store::Store) -> Result<()> { +/// Rotate the local keypair: archive existing keys, generate new ones, register in registry. +pub fn run_rotate(store: &crate::store::Store, args: &RotateKeysArgs) -> Result<()> { let priv_path = keys::private_key_path()?; let pub_path = keys::public_key_path()?; - // Back up old keys if they exist. if priv_path.exists() { - let bak_priv = priv_path.with_extension("key.bak"); - let bak_pub = pub_path.with_extension("key.bak"); - std::fs::rename(&priv_path, &bak_priv) - .with_context(|| format!("backing up private key to {}", bak_priv.display()))?; - std::fs::rename(&pub_path, &bak_pub) - .with_context(|| format!("backing up public key to {}", bak_pub.display()))?; - println!( - " Old keys backed up to {} and {}", - bak_priv.display(), - bak_pub.display() - ); + if let Some(archived) = keys::archive_current_keypair()? { + println!( + " {} previous keypair archived to {}", + ok(), + archived.display() + ); + } } let (kid, _vk) = keys::generate_keypair_at(&priv_path, &pub_path)?; @@ -86,5 +82,59 @@ pub fn run_rotate(store: &crate::store::Store) -> Result<()> { " Run {} to push the updated key registry to GitHub.", "agentdiff push".cyan() ); + + if let Some(n) = args.resign_last.filter(|n| *n > 0) { + resign_last_local_traces(store, n)?; + } + + Ok(()) +} + +/// Re-sign the last `n` JSONL records in the current branch's local trace buffer. +fn resign_last_local_traces(store: &crate::store::Store, n: usize) -> Result<()> { + let branch = store + .current_branch() + .context("detached HEAD — use a branch to re-sign the local trace buffer")?; + let path = store.local_traces_path(&branch); + if !path.exists() { + anyhow::bail!( + "no local trace buffer at {} — nothing to re-sign", + path.display() + ); + } + + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + let mut lines: Vec = raw.lines().map(String::from).collect(); + while lines.last().map(|l| l.trim().is_empty()).unwrap_or(false) { + lines.pop(); + } + anyhow::ensure!(!lines.is_empty(), "local trace buffer is empty"); + + let take = n.min(lines.len()); + let start = lines.len() - take; + for i in start..lines.len() { + let mut val: serde_json::Value = serde_json::from_str(&lines[i]) + .with_context(|| format!("parsing trace line {}", i + 1))?; + if let Some(obj) = val.as_object_mut() { + obj.remove("sig"); + } + let sig = keys::sign_record(&val)?; + val.as_object_mut() + .context("trace entry must be a JSON object")? + .insert("sig".to_string(), serde_json::to_value(&sig)?); + lines[i] = serde_json::to_string(&val)?; + } + + std::fs::write(&path, lines.join("\n") + "\n") + .with_context(|| format!("writing {}", path.display()))?; + + let label = if take == 1 { "entry" } else { "entries" }; + println!( + " {} re-signed last {} local trace {}", + ok(), + take, + label + ); Ok(()) } diff --git a/src/commands/status.rs b/src/commands/status.rs index c9b6a4f..2e94de0 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,8 +1,11 @@ -use anyhow::Result; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; use colored::Colorize; +use std::collections::HashMap; use std::process::Command; use crate::cli::StatusArgs; +use crate::data::AgentTrace; use crate::keys; use crate::store::{self, Store}; use crate::util::{dim, ok, print_command_header, warn}; @@ -245,11 +248,28 @@ fn print_agent_hook_status() { continue; } any_checked = true; - let registered = std::fs::read_to_string(&path) - .map(|s| s.contains(check.marker)) - .unwrap_or(false); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let registered = content.contains(check.marker); if registered { - println!(" {} agent hook {} registered", prefix(ok()), check.name); + // Codex: additionally verify features.codex_hooks = true in config.toml. + if check.name == "codex" { + let hooks_flag = content + .parse::() + .ok() + .and_then(|v| v.get("features")?.get("codex_hooks")?.as_bool()) + .unwrap_or(false); + if hooks_flag { + println!(" {} agent hook codex registered", prefix(ok())); + } else { + println!( + " {} agent hook codex hook registered but features.codex_hooks not enabled — re-run 'agentdiff configure'", + prefix(warn()) + ); + any_missing = true; + } + } else { + println!(" {} agent hook {} registered", prefix(ok()), check.name); + } } else { println!( " {} agent hook {} config found but agentdiff hook missing — re-run 'agentdiff configure'", @@ -265,18 +285,44 @@ fn print_agent_hook_status() { let gemini_dir = home.join(".gemini"); if gemini_dir.exists() { any_checked = true; - let cli_ok = std::fs::read_to_string(gemini_dir.join("settings.json")) - .map(|s| s.contains("capture-antigravity")) - .unwrap_or(false); + let settings_raw = + std::fs::read_to_string(gemini_dir.join("settings.json")).unwrap_or_default(); + let cli_ok = settings_raw.contains("capture-antigravity"); + // Additionally verify tools.enableHooks = true — without this, Gemini ignores hooks + // even when the hook entries are present in settings.json. + let hooks_enabled = cli_ok + && serde_json::from_str::(&settings_raw) + .ok() + .and_then(|v| v.get("tools")?.get("enableHooks")?.as_bool()) + .unwrap_or(false); let rule_ok = std::fs::read_to_string(gemini_dir.join("GEMINI.md")) .map(|s| s.contains("agentdiff: managed block")) .unwrap_or(false); match (cli_ok, rule_ok) { (true, true) => { - println!(" {} agent hook gemini-cli registered; antigravity rule set", prefix(ok())); + if hooks_enabled { + println!( + " {} agent hook gemini-cli registered; antigravity rule set", + prefix(ok()) + ); + } else { + println!( + " {} agent hook gemini-cli registered; antigravity rule set but tools.enableHooks not set — re-run 'agentdiff configure'", + prefix(warn()) + ); + any_missing = true; + } } (true, false) => { - println!(" {} agent hook gemini-cli registered", prefix(ok())); + if hooks_enabled { + println!(" {} agent hook gemini-cli registered", prefix(ok())); + } else { + println!( + " {} agent hook gemini-cli registered but tools.enableHooks not set — re-run 'agentdiff configure'", + prefix(warn()) + ); + any_missing = true; + } println!( " {} agent hook antigravity GEMINI.md rule missing — re-run 'agentdiff configure'", prefix(warn()) @@ -482,10 +528,47 @@ fn run_remote(store: &Store, args: &StatusArgs) -> Result<()> { } } + let since_cutoff: Option> = match &args.since { + None => None, + Some(s) => Some(activity_cutoff_from_since(Utc::now(), s)?), + }; + + print_remote_developer_health(store, &remote_refs, since_cutoff); + println!(); Ok(()) } +/// Oldest UTC instant inside the `--since` window (`7`, `7d`, `48h`): +/// entries with activity at or after this instant pass the filter. +fn activity_cutoff_from_since(now: DateTime, s: &str) -> Result> { + let s = s.trim().to_ascii_lowercase(); + anyhow::ensure!(!s.is_empty(), "--since must not be empty"); + let duration = if let Some(rest) = s.strip_suffix('d') { + let n: i64 = rest + .trim() + .parse() + .context("invalid day count in --since")?; + chrono::Duration::days(n) + } else if let Some(rest) = s.strip_suffix('h') { + let n: i64 = rest + .trim() + .parse() + .context("invalid hour count in --since")?; + chrono::Duration::hours(n) + } else { + let n: i64 = s + .parse() + .context("invalid --since (expected N, Nd, or Nh)")?; + chrono::Duration::days(n) + }; + anyhow::ensure!( + duration > chrono::Duration::zero(), + "--since must be positive" + ); + Ok(now - duration) +} + fn fetch_trace_count(repo_root: &std::path::Path, ref_name: &str) -> Option { if let Some(n) = count_local_ref(repo_root, ref_name) { return Some(n); @@ -510,6 +593,165 @@ fn count_local_ref(repo_root: &std::path::Path, ref_name: &str) -> Option Some(content.lines().filter(|l| !l.trim().is_empty()).count()) } +fn load_traces_from_ref(repo_root: &std::path::Path, ref_name: &str) -> Vec { + let spec = format!("{ref_name}:traces.jsonl"); + let mut raw = Command::new("git") + .args(["show", &spec]) + .current_dir(repo_root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + + if raw.lines().all(|l| l.trim().is_empty()) { + if let Ok(Some(api_raw)) = + store::fetch_ref_content_via_api(repo_root, ref_name, "traces.jsonl") + { + raw = api_raw; + } + } + + store::parse_traces_from_jsonl(&raw) +} + +fn print_remote_developer_health( + store: &Store, + remote_refs: &[(String, String)], + since_cutoff: Option>, +) { + const STALE: chrono::Duration = chrono::Duration::days(7); + + let trace_refs: Vec<_> = remote_refs + .iter() + .filter(|(_, r)| r.starts_with("refs/agentdiff/traces/")) + .collect(); + if trace_refs.is_empty() { + return; + } + + let now = Utc::now(); + + let mut dev_map: HashMap)> = HashMap::new(); + let mut per_ref: Vec<(String, usize, Option>)> = Vec::new(); + + for (_, ref_name) in &trace_refs { + let traces = load_traces_from_ref(&store.repo_root, ref_name); + let mut last: Option> = None; + for t in &traces { + let ts = t.timestamp; + last = Some(match last { + None => ts, + Some(p) => p.max(ts), + }); + let author = t + .agentdiff_metadata() + .and_then(|m| m.author) + .unwrap_or_else(|| "unknown".to_string()); + let entry = dev_map.entry(author).or_insert((0, ts)); + entry.0 += 1; + if ts > entry.1 { + entry.1 = ts; + } + } + per_ref.push(((*ref_name).clone(), traces.len(), last)); + } + + println!(); + println!("{}", " REMOTE TRACE BRANCHES".dimmed()); + let bhdr = format!(" {:<42} {:<8} {}", "REF (truncated)", "TRACES", "LAST TRACE"); + println!("{}", bhdr.dimmed()); + println!(" {}", "─".repeat(76).dimmed()); + + per_ref.sort_by(|a, b| match (a.2, b.2) { + (Some(ta), Some(tb)) => tb.cmp(&ta).then_with(|| a.0.cmp(&b.0)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.0.cmp(&b.0), + }); + + for (ref_name, count, last_ts) in &per_ref { + let short_ref: std::borrow::Cow<'_, str> = if ref_name.len() > 42 { + ref_name[..42].into() + } else { + ref_name.as_str().into() + }; + let (last_str, status_px) = match last_ts { + None => ("(no traces)".to_string(), prefix(warn())), + Some(ts) => { + let age = now.signed_duration_since(*ts); + let s = if age.num_days() > 0 { + format!("{}d ago", age.num_days()) + } else if age.num_hours() > 0 { + format!("{}h ago", age.num_hours()) + } else { + "just now".to_string() + }; + let px = if age > STALE { + prefix(warn()) + } else { + prefix(ok()) + }; + (s, px) + } + }; + println!( + " {} {:<42} {:<8} {}", + status_px, + short_ref, + count, + last_str + ); + } + + if dev_map.is_empty() { + return; + } + + let mut devs: Vec<_> = dev_map.iter().collect(); + devs.sort_by(|a, b| b.1.1.cmp(&a.1.1)); + + let devs: Vec<_> = devs + .into_iter() + .filter(|(_, (_, last_seen))| { + since_cutoff.map_or(true, |cutoff| *last_seen >= cutoff) + }) + .collect(); + + if devs.is_empty() { + return; + } + + println!(); + println!("{}", " DEVELOPERS (from remote traces)".dimmed()); + let hdr = format!(" {:<32} {:<10} {}", "DEVELOPER", "TRACES", "LAST ACTIVE"); + println!("{}", hdr.dimmed()); + println!(" {}", "─".repeat(60).dimmed()); + + for (author, (count, last_seen)) in devs { + let age = now.signed_duration_since(*last_seen); + let age_str = if age.num_days() > 0 { + format!("{}d ago", age.num_days()) + } else if age.num_hours() > 0 { + format!("{}h ago", age.num_hours()) + } else { + "just now".to_string() + }; + let status_prefix = if age.num_days() > 7 { + prefix(warn()) + } else { + prefix(ok()) + }; + println!( + " {} {:<32} {:<10} {}", + status_prefix, + author, + count, + age_str + ); + } +} + fn local_ref_status(store: &Store, ref_name: &str) -> String { let local_sha = Command::new("git") .args(["rev-parse", ref_name]) diff --git a/src/commands/verify.rs b/src/commands/verify.rs index 109e1b0..0617062 100644 --- a/src/commands/verify.rs +++ b/src/commands/verify.rs @@ -109,22 +109,26 @@ pub fn run(store: &Store, args: &VerifyArgs) -> Result<()> { Ok(k) => { key_cache.insert(kid.to_string(), k); } - Err(_) => { - // Registry miss — no key available for this record. - invalid_sig += 1; - eprintln!( - "{} {} — key_id {} not found locally or in registry \ - (run 'agentdiff keys register' on the signing machine)", - err(), - short, - &kid[..kid.len().min(16)] - ); - if args.strict { - print_summary(to_verify.len(), valid, missing_sig, invalid_sig); - std::process::exit(2); + Err(_) => match keys::try_load_archived_verifying_key(kid)? { + Some(k) => { + key_cache.insert(kid.to_string(), k); } - continue; - } + None => { + invalid_sig += 1; + eprintln!( + "{} {} — key_id {} not found locally, in registry, or in ~/.agentdiff/keys/archive \ + (run 'agentdiff keys register' on the signing machine)", + err(), + short, + &kid[..kid.len().min(16)] + ); + if args.strict { + print_summary(to_verify.len(), valid, missing_sig, invalid_sig); + std::process::exit(2); + } + continue; + } + }, } } key_cache.get(kid).unwrap() diff --git a/src/keys.rs b/src/keys.rs index 8ee0a35..60f45a6 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1,9 +1,11 @@ use anyhow::{Context, Result}; use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand_core::OsRng; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::data::LedgerSig; @@ -20,6 +22,135 @@ pub fn public_key_path() -> Result { Ok(keys_dir()?.join("public.key")) } +/// `~/.agentdiff/keys/archive/` — rotated key material for audit and verification. +pub fn archive_dir() -> Result { + Ok(keys_dir()?.join("archive")) +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArchivedKeyMeta { + key_id: String, + archived_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option>, +} + +/// Move the current `private.key` / `public.key` into a timestamped archive folder. +/// Returns `Ok(None)` if no keys exist on the canonical paths. +pub fn archive_current_keypair() -> Result> { + let priv_path = private_key_path()?; + let pub_path = public_key_path()?; + if !priv_path.exists() { + return Ok(None); + } + anyhow::ensure!( + pub_path.exists(), + "public key missing at {} — cannot archive safely", + pub_path.display() + ); + + let vk = load_verifying_key().context("reading current public key for archive")?; + let kid = compute_key_id(&vk); + + let dest = archive_dir()?.join(format!( + "{}_{}", + Utc::now().format("%Y%m%dT%H%M%SZ"), + kid + )); + std::fs::create_dir_all(&dest) + .with_context(|| format!("creating archive dir {}", dest.display()))?; + + let dest_priv = dest.join("private.key"); + let dest_pub = dest.join("public.key"); + std::fs::rename(&priv_path, &dest_priv) + .with_context(|| format!("archiving private key to {}", dest_priv.display()))?; + std::fs::rename(&pub_path, &dest_pub) + .with_context(|| format!("archiving public key to {}", dest_pub.display()))?; + + let meta = ArchivedKeyMeta { + key_id: kid, + archived_at: Utc::now(), + expires_at: None, + }; + let meta_path = dest.join("archive.toml"); + std::fs::write( + &meta_path, + toml::to_string_pretty(&meta).context("serializing archive metadata")?, + ) + .with_context(|| format!("writing {}", meta_path.display()))?; + + Ok(Some(dest)) +} + +/// Load a verifying key from the local archive when the git registry has no entry yet. +pub fn try_load_archived_verifying_key(key_id: &str) -> Result> { + let root = match archive_dir() { + Ok(p) => p, + Err(_) => return Ok(None), + }; + if !root.is_dir() { + return Ok(None); + } + + let now = Utc::now(); + for entry in std::fs::read_dir(&root).with_context(|| format!("reading {}", root.display()))? { + let entry = entry.context("archive dir entry")?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let meta_path = path.join("archive.toml"); + let (meta_kid, expired) = if meta_path.is_file() { + let raw = std::fs::read_to_string(&meta_path).unwrap_or_default(); + let meta: ArchivedKeyMeta = match toml::from_str(&raw) { + Ok(m) => m, + Err(_) => continue, + }; + let expired = meta + .expires_at + .is_some_and(|ex| ex < now); + (meta.key_id, expired) + } else { + // Legacy folder: infer from public.key only. + (String::new(), false) + }; + + if !meta_kid.is_empty() && meta_kid != key_id { + continue; + } + if expired { + continue; + } + + let pub_path = path.join("public.key"); + if !pub_path.is_file() { + continue; + } + + let vk = read_verifying_key_file(&pub_path)?; + let kid = compute_key_id(&vk); + if kid != key_id { + continue; + } + return Ok(Some(vk)); + } + + Ok(None) +} + +fn read_verifying_key_file(path: &Path) -> Result { + let b64 = std::fs::read_to_string(path) + .with_context(|| format!("cannot read public key at {}", path.display()))?; + let bytes = STANDARD + .decode(b64.trim()) + .context("cannot base64-decode archived public key")?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("archived public key must be 32 bytes"))?; + VerifyingKey::from_bytes(&arr).context("invalid ed25519 public key in archive") +} + /// Generate and persist a new ed25519 keypair. /// Errors if a private key already exists. pub fn generate_keypair() -> Result<(PathBuf, PathBuf, String)> { diff --git a/src/main.rs b/src/main.rs index 87489db..9de1d44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,7 +72,7 @@ fn main() -> anyhow::Result<()> { Command::Keys(args) => match args.action { cli::KeysAction::Init => commands::keys::run_init(), cli::KeysAction::Register => commands::keys::run_register(&store), - cli::KeysAction::Rotate => commands::keys::run_rotate(&store), + cli::KeysAction::Rotate(args) => commands::keys::run_rotate(&store, &args), }, Command::Verify(args) => commands::verify::run(&store, &args), Command::Policy(args) => commands::policy::run(&store, &args.action),