use crate::models::field_names;
use std::fs;
use std::io::{BufRead, BufReader};
#[cfg(test)]
use std::path::Path;
use anyhow::Result;
use clap::{Args, Subcommand};
use crate::audit::{
AuditEvent, resolve_audit_path, resolve_audit_path_with_override, verify_chain,
};
use crate::cli::CliOutput;
use crate::config::AppConfig;
#[derive(Args)]
pub struct AuditArgs {
#[command(subcommand)]
pub action: AuditAction,
#[arg(long, global = true, value_name = "PATH")]
pub audit_dir: Option<std::path::PathBuf>,
}
#[derive(Subcommand)]
pub enum AuditAction {
Verify(VerifyArgs),
Tail(TailArgs),
Path,
Show(ShowArgs),
}
#[derive(Args)]
pub struct ShowArgs {
#[arg(long)]
pub capability_expansions: bool,
#[arg(long, value_name = "AGENT_ID")]
pub agent_id: Option<String>,
#[arg(long, default_value_t = 50)]
pub limit: usize,
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct VerifyArgs {
#[arg(long)]
pub path: Option<String>,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, value_name = "ISO_DATE")]
pub since: Option<String>,
#[arg(long, value_name = "AGENT_ID")]
pub forensic_agent_id: Option<String>,
}
#[derive(Args)]
pub struct TailArgs {
#[arg(long)]
pub path: Option<String>,
#[arg(long, default_value_t = 50)]
pub lines: usize,
#[arg(long)]
pub actor: Option<String>,
#[arg(long)]
pub namespace: Option<String>,
#[arg(long)]
pub action: Option<String>,
#[arg(long, default_value = "json")]
pub format: String,
}
pub fn run(args: AuditArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
let audit_dir = args.audit_dir.clone();
match args.action {
AuditAction::Verify(v) => run_verify(&v, audit_dir.as_deref(), app_config, out),
AuditAction::Tail(t) => run_tail(&t, audit_dir.as_deref(), app_config, out),
AuditAction::Path => run_path(audit_dir.as_deref(), app_config, out),
AuditAction::Show(s) => run_show(&s, app_config, out),
}
}
fn run_show(args: &ShowArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
let db_path = app_config.effective_db(std::path::Path::new("ai-memory.db"));
let conn = crate::db::open(&db_path)?;
let rows = crate::db::list_capability_expansions(&conn, args.limit, args.agent_id.as_deref())?;
if args.json {
let payload: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r.id,
"agent_id": r.agent_id,
"event_type": r.event_type,
"requested_family": r.requested_family,
"granted": r.granted,
"attestation_tier": r.attestation_tier,
"timestamp": r.timestamp,
})
})
.collect();
writeln!(out.stdout, "{}", serde_json::to_string_pretty(&payload)?)?;
return Ok(0);
}
if rows.is_empty() {
writeln!(out.stdout, "audit_log: no rows")?;
return Ok(0);
}
writeln!(
out.stdout,
"{:<25} {:<7} {:<12} {:<32} {:<6}",
"timestamp", "granted", "family", "agent_id", "event"
)?;
for r in &rows {
let aid = r.agent_id.as_deref().unwrap_or("<anonymous>");
let fam = r.requested_family.as_deref().unwrap_or("-");
writeln!(
out.stdout,
"{:<25} {:<7} {:<12} {:<32} {:<6}",
r.timestamp,
if r.granted { "ALLOW" } else { "DENY" },
fam,
aid,
r.event_type
)?;
}
Ok(0)
}
fn resolve_path(
app_config: &AppConfig,
cli_audit_dir: Option<&std::path::Path>,
explicit_per_cmd: Option<&str>,
) -> std::path::PathBuf {
if let Some(p) = explicit_per_cmd {
return std::path::PathBuf::from(crate::audit::expand_tilde(p));
}
let cfg = app_config.effective_audit();
if let Ok((p, _src)) = resolve_audit_path_with_override(cli_audit_dir, &cfg) {
return p;
}
resolve_audit_path(&cfg)
}
fn run_verify(
args: &VerifyArgs,
cli_audit_dir: Option<&std::path::Path>,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<i32> {
if let Some(since) = args.since.as_deref() {
return run_forensic_verify(since, args, cli_audit_dir, app_config, out);
}
let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
if !path.exists() {
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "ok",
(field_names::TOTAL_LINES): 0,
"note": "audit log does not exist (audit may be disabled)",
"path": path.display().to_string(),
})
)?;
} else {
writeln!(
out.stdout,
"audit verify: log not present at {} — nothing to check",
path.display()
)?;
}
return Ok(0);
}
let report = verify_chain(&path)?;
if let Some(failure) = &report.first_failure {
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "fail",
(field_names::TOTAL_LINES): report.total_lines,
"failure": {
"line_number": failure.line_number,
"kind": format!("{:?}", failure.kind),
"detail": failure.detail,
},
"path": path.display().to_string(),
})
)?;
} else {
writeln!(
out.stderr,
"audit verify FAIL at line {}: {:?} — {}",
failure.line_number, failure.kind, failure.detail
)?;
}
return Ok(2);
}
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "ok",
(field_names::TOTAL_LINES): report.total_lines,
"path": path.display().to_string(),
})
)?;
} else {
writeln!(
out.stdout,
"audit verify OK: {} line(s) verified at {}",
report.total_lines,
path.display()
)?;
}
Ok(0)
}
fn run_forensic_verify(
since: &str,
args: &VerifyArgs,
cli_audit_dir: Option<&std::path::Path>,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let log_path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
let dir = log_path
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let agent_id = args
.forensic_agent_id
.clone()
.or_else(|| crate::identity::resolve_agent_id(None, None).ok())
.unwrap_or_else(|| "ai-memory".to_string());
let public_key = crate::governance::audit::load_daemon_verifying_key(&agent_id).unwrap_or(None);
let report = match crate::governance::audit::verify_since(&dir, since, public_key.as_ref()) {
Ok(r) => r,
Err(e) => {
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "error",
"since": since,
"dir": dir.display().to_string(),
"error": e.to_string(),
})
)?;
} else {
writeln!(
out.stderr,
"forensic verify error: {e} (dir={})",
dir.display()
)?;
}
return Ok(2);
}
};
if let Some(failure) = &report.first_failure {
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "fail",
(field_names::TOTAL_LINES): report.total_lines,
"unsigned_lines": report.unsigned_lines,
"failure": {
"file": failure.file.display().to_string(),
"line_number": failure.line_number,
"kind": format!("{:?}", failure.kind),
"detail": failure.detail,
},
"since": since,
"dir": dir.display().to_string(),
})
)?;
} else {
writeln!(
out.stderr,
"forensic verify FAIL at {}:{} — {:?}: {}",
failure.file.display(),
failure.line_number,
failure.kind,
failure.detail
)?;
}
return Ok(2);
}
if args.json {
writeln!(
out.stdout,
"{}",
serde_json::json!({
"status": "ok",
(field_names::TOTAL_LINES): report.total_lines,
"unsigned_lines": report.unsigned_lines,
"since": since,
"dir": dir.display().to_string(),
})
)?;
} else {
writeln!(
out.stdout,
"forensic verify OK: {} line(s) verified since {} ({} unsigned) at {}",
report.total_lines,
since,
report.unsigned_lines,
dir.display()
)?;
}
Ok(0)
}
fn run_tail(
args: &TailArgs,
cli_audit_dir: Option<&std::path::Path>,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
if !path.exists() {
return Ok(0);
}
let f = fs::File::open(&path)?;
let buf = BufReader::new(f);
let mut keep: Vec<AuditEvent> = Vec::new();
for line in buf.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) else {
continue;
};
if let Some(actor) = &args.actor
&& !ev.actor.agent_id.contains(actor)
{
continue;
}
if let Some(ns) = &args.namespace
&& ev.target.namespace != *ns
{
continue;
}
if let Some(action) = &args.action
&& ev.action.as_str() != action
{
continue;
}
keep.push(ev);
if keep.len() > args.lines {
keep.remove(0);
}
}
let json_format = args.format != "text";
for ev in &keep {
if json_format {
writeln!(out.stdout, "{}", serde_json::to_string(ev)?)?;
} else {
writeln!(
out.stdout,
"{} seq={} {} {} ns={} id={} outcome={:?}",
ev.timestamp,
ev.sequence,
ev.actor.agent_id,
ev.action.as_str(),
ev.target.namespace,
ev.target.memory_id,
ev.outcome,
)?;
}
}
Ok(0)
}
fn run_path(
cli_audit_dir: Option<&std::path::Path>,
app_config: &AppConfig,
out: &mut CliOutput<'_>,
) -> Result<i32> {
let p = resolve_path(app_config, cli_audit_dir, None);
writeln!(out.stdout, "{}", p.display())?;
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{
AuditAction as AAct, AuditOutcome, CHAIN_HEAD_PREV_HASH, EventBuilder, actor, target_memory,
};
use crate::config::AuditConfig;
use crate::models::Tier;
fn write_chained_log(dir: &Path) -> std::path::PathBuf {
let path = dir.join("audit.log");
let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
let mut buf = String::new();
for seq in 1..=3 {
let ev = make_event(seq, &prev_hash);
prev_hash = ev.self_hash.clone();
buf.push_str(&serde_json::to_string(&ev).unwrap());
buf.push('\n');
}
fs::write(&path, buf).unwrap();
path
}
fn make_event(seq: u64, prev: &str) -> AuditEvent {
let mut ev = AuditEvent {
schema_version: crate::audit::SCHEMA_VERSION,
timestamp: format!("2026-04-30T00:00:0{seq}+00:00"),
sequence: seq,
actor: actor("ai:test@host:pid-1", "host_fallback", None),
action: AAct::Store,
target: target_memory(
format!("mem-{seq}"),
"ns-x",
Some("title".to_string()),
Some(Tier::Mid.as_str().to_string()),
None,
),
outcome: AuditOutcome::Allow,
auth: None,
session_id: None,
request_id: None,
error: None,
prev_hash: prev.to_string(),
self_hash: String::new(),
};
let canonical = {
let mut clone = ev.clone();
clone.self_hash.clear();
serde_json::to_string(&clone).unwrap()
};
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(canonical.as_bytes());
let bytes = h.finalize();
let mut s = String::with_capacity(64);
for b in bytes.iter() {
s.push_str(&format!("{b:02x}"));
}
ev.self_hash = s;
ev
}
#[test]
fn audit_verify_subcmd_reports_ok_for_valid_chain() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig {
audit: Some(AuditConfig {
enabled: Some(true),
path: Some(p.to_string_lossy().into_owned()),
..Default::default()
}),
..Default::default()
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(p.to_string_lossy().into_owned()),
json: true,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["status"], "ok");
assert_eq!(v["total_lines"], 3);
}
#[test]
fn audit_verify_subcmd_detects_tampering() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let mut body = fs::read_to_string(&p).unwrap();
body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
fs::write(&p, body).unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(p.to_string_lossy().into_owned()),
json: true,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 2, "tampering must produce non-zero exit");
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["status"], "fail");
}
#[test]
fn audit_verify_subcmd_missing_log_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
json: false,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("nothing to check"));
}
#[test]
fn audit_path_subcmd_prints_resolved_path() {
let cfg = AppConfig {
audit: Some(AuditConfig {
path: Some("/var/log/ai-memory/custom.log".to_string()),
..Default::default()
}),
..Default::default()
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_path(None, &cfg, &mut out).unwrap();
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("/var/log/ai-memory/custom.log"));
}
#[test]
fn audit_path_subcmd_honours_audit_dir_flag() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
run_path(Some(tmp.path()), &cfg, &mut out).unwrap();
let s = std::str::from_utf8(&stdout).unwrap();
assert!(
s.contains(tmp.path().to_string_lossy().as_ref()),
"expected audit-dir override to surface in `audit path` output: {s}"
);
assert!(s.contains("audit.log"));
}
#[allow(dead_code)]
fn _builder_is_visible() {
let _ = EventBuilder::new(
AAct::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
);
}
#[test]
fn audit_run_dispatches_to_verify_arm() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = AuditArgs {
action: AuditAction::Verify(VerifyArgs {
path: Some(p.to_string_lossy().into_owned()),
json: true,
since: None,
forensic_agent_id: None,
}),
audit_dir: None,
};
let exit = run(args, &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("\"status\":\"ok\""), "got: {s}");
}
#[test]
fn audit_run_dispatches_to_tail_arm() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = AuditArgs {
action: AuditAction::Tail(TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 10,
actor: None,
namespace: None,
action: None,
format: "json".to_string(),
}),
audit_dir: None,
};
let exit = run(args, &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let count = s.lines().filter(|l| !l.is_empty()).count();
assert_eq!(count, 3, "expected 3 events from chain, got {count}: {s}");
}
#[test]
fn audit_run_dispatches_to_path_arm() {
let cfg = AppConfig {
audit: Some(AuditConfig {
path: Some("/var/log/ai-memory/from-run.log".to_string()),
..Default::default()
}),
..Default::default()
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = AuditArgs {
action: AuditAction::Path,
audit_dir: None,
};
let exit = run(args, &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("from-run.log"), "got: {s}");
}
#[test]
fn audit_tail_subcmd_returns_last_n_events_in_text_format() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 2,
actor: None,
namespace: None,
action: None,
format: "text".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("seq="), "expected text format: {s}");
let count = s.lines().filter(|l| !l.is_empty()).count();
assert_eq!(count, 2, "lines arg must cap output at 2: {s}");
}
#[test]
fn audit_tail_subcmd_emits_json_by_default() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 50,
actor: None,
namespace: None,
action: None,
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let first = s.lines().next().expect("at least one line");
let v: serde_json::Value = serde_json::from_str(first).expect("json");
assert_eq!(v["schema_version"], 1);
assert!(v.get("self_hash").is_some());
}
#[test]
fn audit_tail_subcmd_filters_by_actor() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 50,
actor: Some("nope-not-in-log".to_string()),
namespace: None,
action: None,
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.is_empty(), "actor filter must drop all events: {s}");
}
#[test]
fn audit_tail_subcmd_filters_by_namespace() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 50,
actor: None,
namespace: Some("not-ns-x".to_string()),
action: None,
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
assert!(stdout.is_empty(), "namespace filter must drop everything");
}
#[test]
fn audit_tail_subcmd_filters_by_action_string() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 50,
actor: None,
namespace: None,
action: Some("delete".to_string()),
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
assert!(
stdout.is_empty(),
"action=delete must drop all store events"
);
}
#[test]
fn audit_tail_subcmd_returns_zero_when_log_missing() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(
tmp.path()
.join("does-not-exist.log")
.to_string_lossy()
.into_owned(),
),
lines: 50,
actor: None,
namespace: None,
action: None,
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
assert!(stdout.is_empty());
}
#[test]
fn audit_tail_subcmd_skips_malformed_lines() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let mut body = fs::read_to_string(&p).unwrap();
body.push_str("not-valid-json\n\n");
fs::write(&p, body).unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_tail(
&TailArgs {
path: Some(p.to_string_lossy().into_owned()),
lines: 50,
actor: None,
namespace: None,
action: None,
format: "json".to_string(),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let count = s.lines().filter(|l| !l.is_empty()).count();
assert_eq!(
count, 3,
"must skip malformed line and keep the 3 good events"
);
}
#[test]
fn audit_verify_subcmd_missing_log_emits_json_when_flag_set() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
json: true,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["status"], "ok");
assert_eq!(v["total_lines"], 0);
assert!(v["note"].as_str().unwrap().contains("does not exist"));
}
#[test]
fn audit_verify_subcmd_text_failure_writes_to_stderr() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let mut body = fs::read_to_string(&p).unwrap();
body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
fs::write(&p, body).unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(p.to_string_lossy().into_owned()),
json: false,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 2);
let serr = std::str::from_utf8(&stderr).unwrap();
assert!(
serr.contains("audit verify FAIL"),
"expected text-format failure on stderr: {serr}"
);
}
#[test]
fn audit_verify_subcmd_text_success_writes_to_stdout() {
let tmp = tempfile::tempdir().unwrap();
let p = write_chained_log(tmp.path());
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(p.to_string_lossy().into_owned()),
json: false,
since: None,
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("audit verify OK"), "got: {s}");
assert!(s.contains("3 line(s) verified"));
}
fn show_args(json: bool, agent_id: Option<&str>) -> ShowArgs {
ShowArgs {
capability_expansions: false,
agent_id: agent_id.map(str::to_string),
limit: 50,
json,
}
}
fn cfg_for_db(p: &std::path::Path) -> AppConfig {
AppConfig {
db: Some(p.to_string_lossy().into_owned()),
..AppConfig::default()
}
}
#[test]
fn audit_show_emits_no_rows_message_on_empty_table() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cfg = cfg_for_db(tmp.path());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("audit_log: no rows"), "got: {s}");
}
#[test]
fn audit_show_renders_grant_and_deny_rows_in_text_format() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cfg = cfg_for_db(tmp.path());
let conn = crate::db::open(tmp.path()).unwrap();
crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
drop(conn);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("ALLOW"), "missing ALLOW header in: {s}");
assert!(s.contains("DENY"), "missing DENY header in: {s}");
assert!(s.contains("alice"));
assert!(s.contains("bob"));
assert!(s.contains("graph"));
assert!(s.contains("power"));
}
#[test]
fn audit_show_emits_valid_json_when_flag_set() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cfg = cfg_for_db(tmp.path());
let conn = crate::db::open(tmp.path()).unwrap();
crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
drop(conn);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_show(&show_args(true, None), &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s).expect("--json must emit valid JSON");
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["agent_id"], "alice");
assert_eq!(arr[0]["requested_family"], "graph");
assert_eq!(arr[0]["granted"], true);
assert_eq!(arr[0]["event_type"], "capability_expansion");
}
#[test]
fn audit_show_filters_by_agent_id() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cfg = cfg_for_db(tmp.path());
let conn = crate::db::open(tmp.path()).unwrap();
crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
drop(conn);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_show(&show_args(true, Some("alice")), &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s).unwrap();
let arr = v.as_array().unwrap();
assert_eq!(arr.len(), 1, "filter should leave only alice rows");
assert_eq!(arr[0]["agent_id"], "alice");
}
#[test]
fn audit_run_dispatches_to_show_arm() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let cfg = cfg_for_db(tmp.path());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let args = AuditArgs {
action: AuditAction::Show(show_args(false, None)),
audit_dir: None,
};
let exit = run(args, &cfg, &mut out).unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.contains("audit_log"), "got: {s}");
}
#[test]
fn audit_verify_with_since_dispatches_to_forensic_verify_json() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
json: true,
since: Some("2026-01-01".into()),
forensic_agent_id: Some("ai:nobody-test".into()),
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["status"], "ok");
assert_eq!(v["total_lines"], 0);
assert_eq!(v["since"], "2026-01-01");
}
#[test]
fn audit_verify_with_since_human_render_emits_summary_line() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
json: false,
since: Some("2026-01-01".into()),
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 0);
let s = std::str::from_utf8(&stdout).unwrap();
assert!(s.starts_with("forensic verify OK:"), "got: {s}");
assert!(s.contains("0 line(s)"));
assert!(s.contains("since 2026-01-01"));
}
#[test]
fn audit_verify_with_since_returns_error_on_unparseable_date() {
let tmp = tempfile::tempdir().unwrap();
let cfg = AppConfig::default();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let exit = run_verify(
&VerifyArgs {
path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
json: true,
since: Some("not-a-date".into()),
forensic_agent_id: None,
},
None,
&cfg,
&mut out,
)
.unwrap();
assert_eq!(exit, 2);
let s = std::str::from_utf8(&stdout).unwrap();
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
assert_eq!(v["status"], "error");
assert!(v["error"].as_str().unwrap().contains("parsing --since"));
}
}