Skip to main content

ai_memory/cli/
audit.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory audit` — operator-facing CLI for the security audit
5//! trail (PR-5 of issue #487).
6//!
7//! Subcommands:
8//! - `verify` — walk the configured audit log and assert the hash chain
9//!   is intact. Exits non-zero on any mismatch with the precise line
10//!   number and failure kind.
11//! - `tail` — print recent audit events in JSON or text form.
12//! - `path` — print the resolved audit log path. Useful for SIEM
13//!   ingestion configuration scripts.
14
15use crate::models::field_names;
16use std::fs;
17use std::io::{BufRead, BufReader};
18#[cfg(test)]
19use std::path::Path;
20
21use anyhow::Result;
22use clap::{Args, Subcommand};
23
24use crate::audit::{
25    AuditEvent, resolve_audit_path, resolve_audit_path_with_override, verify_chain,
26};
27use crate::cli::CliOutput;
28use crate::config::AppConfig;
29
30#[derive(Args)]
31pub struct AuditArgs {
32    #[command(subcommand)]
33    pub action: AuditAction,
34    /// Override the audit log directory. Highest-priority layer in the
35    /// resolution ladder (CLI > `AI_MEMORY_AUDIT_DIR` > `[audit] path`
36    /// in config.toml > platform default). Refuses world-writable
37    /// directories — see `docs/security/audit-trail.md`.
38    #[arg(long, global = true, value_name = "PATH")]
39    pub audit_dir: Option<std::path::PathBuf>,
40}
41
42#[derive(Subcommand)]
43pub enum AuditAction {
44    /// Verify the hash chain. Exits 0 on success, 2 on mismatch.
45    Verify(VerifyArgs),
46    /// Print the most recent N events (default 50).
47    Tail(TailArgs),
48    /// Print the resolved audit log path.
49    Path,
50    /// v0.6.4-009 — list rows from the in-DB `audit_log` table
51    /// (capability expansions, future event types). Reads the SQLite
52    /// audit_log table; orthogonal to the file-based hash-chained
53    /// trail surfaced by `tail` / `verify`.
54    Show(ShowArgs),
55}
56
57#[derive(Args)]
58pub struct ShowArgs {
59    /// Restrict to capability-expansion events (today the only event
60    /// type written to audit_log; reserves the option for future
61    /// event types).
62    #[arg(long)]
63    pub capability_expansions: bool,
64    /// Filter by exact agent_id match.
65    #[arg(long, value_name = "AGENT_ID")]
66    pub agent_id: Option<String>,
67    /// Maximum rows to return (default 50, max 10000).
68    #[arg(long, default_value_t = 50)]
69    pub limit: usize,
70    /// Emit JSON instead of human-readable text.
71    #[arg(long)]
72    pub json: bool,
73}
74
75#[derive(Args)]
76pub struct VerifyArgs {
77    /// Override the configured audit log path.
78    #[arg(long)]
79    pub path: Option<String>,
80    /// Emit a JSON report instead of text.
81    #[arg(long, default_value_t = false)]
82    pub json: bool,
83    /// v0.7.0 #697 — verify the **forensic** governance-decision log
84    /// (Ed25519-signed, daily-rotated `forensic-<YYYY-MM-DD>.jsonl`)
85    /// from the supplied ISO date forward. Walks every file at or
86    /// after `<YYYY-MM-DD>` under the resolved forensic directory
87    /// (`<audit_dir>/` by default; overridable via the global
88    /// `--audit-dir` flag). Mutually exclusive with the default
89    /// hash-chain verifier (which operates on the flat `audit.log`).
90    #[arg(long, value_name = "ISO_DATE")]
91    pub since: Option<String>,
92    /// v0.7.0 #697 — agent_id whose Ed25519 public key is used to
93    /// verify signatures on the forensic log. Defaults to the
94    /// resolved daemon agent_id (same precedence as the rest of the
95    /// CLI). Use the matching `--agent-id` if your daemon signs under
96    /// a non-default identity.
97    #[arg(long, value_name = "AGENT_ID")]
98    pub forensic_agent_id: Option<String>,
99}
100
101#[derive(Args)]
102pub struct TailArgs {
103    /// Override the configured audit log path.
104    #[arg(long)]
105    pub path: Option<String>,
106    /// Number of trailing lines to print. Default 50.
107    #[arg(long, default_value_t = 50)]
108    pub lines: usize,
109    /// Filter by `actor.agent_id`.
110    #[arg(long)]
111    pub actor: Option<String>,
112    /// Filter by `target.namespace`.
113    #[arg(long)]
114    pub namespace: Option<String>,
115    /// Filter by `action`.
116    #[arg(long)]
117    pub action: Option<String>,
118    /// Output format: `json` (default) or `text`.
119    #[arg(long, default_value = "json")]
120    pub format: String,
121}
122
123/// `ai-memory audit` entry point. Returns the desired process exit
124/// code so the caller can surface a non-zero status from the top-level
125/// dispatch without panicking.
126pub fn run(args: AuditArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
127    let audit_dir = args.audit_dir.clone();
128    match args.action {
129        AuditAction::Verify(v) => run_verify(&v, audit_dir.as_deref(), app_config, out),
130        AuditAction::Tail(t) => run_tail(&t, audit_dir.as_deref(), app_config, out),
131        AuditAction::Path => run_path(audit_dir.as_deref(), app_config, out),
132        AuditAction::Show(s) => run_show(&s, app_config, out),
133    }
134}
135
136/// v0.6.4-009 — print rows from the `audit_log` SQLite table.
137fn run_show(args: &ShowArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
138    let db_path = app_config.effective_db(std::path::Path::new("ai-memory.db"));
139    let conn = crate::db::open(&db_path)?;
140    let rows = crate::db::list_capability_expansions(&conn, args.limit, args.agent_id.as_deref())?;
141    if args.json {
142        let payload: Vec<serde_json::Value> = rows
143            .iter()
144            .map(|r| {
145                serde_json::json!({
146                    "id": r.id,
147                    "agent_id": r.agent_id,
148                    "event_type": r.event_type,
149                    "requested_family": r.requested_family,
150                    "granted": r.granted,
151                    "attestation_tier": r.attestation_tier,
152                    "timestamp": r.timestamp,
153                })
154            })
155            .collect();
156        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&payload)?)?;
157        return Ok(0);
158    }
159    if rows.is_empty() {
160        writeln!(out.stdout, "audit_log: no rows")?;
161        return Ok(0);
162    }
163    writeln!(
164        out.stdout,
165        "{:<25} {:<7} {:<12} {:<32} {:<6}",
166        "timestamp", "granted", "family", "agent_id", "event"
167    )?;
168    for r in &rows {
169        let aid = r.agent_id.as_deref().unwrap_or("<anonymous>");
170        let fam = r.requested_family.as_deref().unwrap_or("-");
171        writeln!(
172            out.stdout,
173            "{:<25} {:<7} {:<12} {:<32} {:<6}",
174            r.timestamp,
175            if r.granted { "ALLOW" } else { "DENY" },
176            fam,
177            aid,
178            r.event_type
179        )?;
180    }
181    Ok(0)
182}
183
184/// Resolve the audit log path honouring (in order): explicit per-subcommand
185/// `--path` (legacy `VerifyArgs.path` / `TailArgs.path`), the global
186/// `--audit-dir` flag, `AI_MEMORY_AUDIT_DIR`, `[audit] path` in
187/// config.toml, and the platform default. Falls back to the loose
188/// `resolve_audit_path` if any layer above produces an error so the
189/// `audit path` subcommand can still print a useful answer when
190/// `--audit-dir` is mistyped.
191fn resolve_path(
192    app_config: &AppConfig,
193    cli_audit_dir: Option<&std::path::Path>,
194    explicit_per_cmd: Option<&str>,
195) -> std::path::PathBuf {
196    if let Some(p) = explicit_per_cmd {
197        return std::path::PathBuf::from(crate::audit::expand_tilde(p));
198    }
199    let cfg = app_config.effective_audit();
200    if let Ok((p, _src)) = resolve_audit_path_with_override(cli_audit_dir, &cfg) {
201        return p;
202    }
203    resolve_audit_path(&cfg)
204}
205
206fn run_verify(
207    args: &VerifyArgs,
208    cli_audit_dir: Option<&std::path::Path>,
209    app_config: &AppConfig,
210    out: &mut CliOutput<'_>,
211) -> Result<i32> {
212    // v0.7.0 #697 — forensic verify (Ed25519-signed, daily-rotated)
213    // takes priority when `--since` is supplied. The flat `audit.log`
214    // verifier ignores the `--since` semantic by design (that log is
215    // a single file).
216    if let Some(since) = args.since.as_deref() {
217        return run_forensic_verify(since, args, cli_audit_dir, app_config, out);
218    }
219    let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
220    if !path.exists() {
221        if args.json {
222            writeln!(
223                out.stdout,
224                "{}",
225                serde_json::json!({
226                    "status": "ok",
227                    (field_names::TOTAL_LINES): 0,
228                    "note": "audit log does not exist (audit may be disabled)",
229                    "path": path.display().to_string(),
230                })
231            )?;
232        } else {
233            writeln!(
234                out.stdout,
235                "audit verify: log not present at {} — nothing to check",
236                path.display()
237            )?;
238        }
239        return Ok(0);
240    }
241    let report = verify_chain(&path)?;
242    if let Some(failure) = &report.first_failure {
243        if args.json {
244            writeln!(
245                out.stdout,
246                "{}",
247                serde_json::json!({
248                    "status": "fail",
249                    (field_names::TOTAL_LINES): report.total_lines,
250                    "failure": {
251                        "line_number": failure.line_number,
252                        "kind": format!("{:?}", failure.kind),
253                        "detail": failure.detail,
254                    },
255                    "path": path.display().to_string(),
256                })
257            )?;
258        } else {
259            writeln!(
260                out.stderr,
261                "audit verify FAIL at line {}: {:?} — {}",
262                failure.line_number, failure.kind, failure.detail
263            )?;
264        }
265        return Ok(2);
266    }
267    if args.json {
268        writeln!(
269            out.stdout,
270            "{}",
271            serde_json::json!({
272                "status": "ok",
273                (field_names::TOTAL_LINES): report.total_lines,
274                "path": path.display().to_string(),
275            })
276        )?;
277    } else {
278        writeln!(
279            out.stdout,
280            "audit verify OK: {} line(s) verified at {}",
281            report.total_lines,
282            path.display()
283        )?;
284    }
285    Ok(0)
286}
287
288/// v0.7.0 #697 — forensic verify dispatch. Resolves the forensic
289/// directory (default = same as the flat audit-log dir), loads the
290/// daemon's Ed25519 public key, and walks every
291/// `forensic-<YYYY-MM-DD>.jsonl` file at or after `--since`.
292///
293/// Exit codes mirror the flat verifier:
294/// - `0` — chain intact (signed or unsigned)
295/// - `2` — at least one row failed
296fn run_forensic_verify(
297    since: &str,
298    args: &VerifyArgs,
299    cli_audit_dir: Option<&std::path::Path>,
300    app_config: &AppConfig,
301    out: &mut CliOutput<'_>,
302) -> Result<i32> {
303    // Forensic files live alongside the flat audit.log file — same
304    // resolution ladder. Walk-up from the resolved audit log file to
305    // its directory.
306    let log_path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
307    let dir = log_path
308        .parent()
309        .map(std::path::Path::to_path_buf)
310        .unwrap_or_else(|| std::path::PathBuf::from("."));
311
312    // Resolve the agent_id whose pubkey signs the forensic log. The
313    // operator can override via `--forensic-agent-id` for off-default
314    // signers (e.g. an HSM-rotated key). Falls back to the resolved
315    // daemon agent_id.
316    let agent_id = args
317        .forensic_agent_id
318        .clone()
319        .or_else(|| crate::identity::resolve_agent_id(None, None).ok())
320        .unwrap_or_else(|| "ai-memory".to_string());
321
322    let public_key = crate::governance::audit::load_daemon_verifying_key(&agent_id).unwrap_or(None);
323
324    let report = match crate::governance::audit::verify_since(&dir, since, public_key.as_ref()) {
325        Ok(r) => r,
326        Err(e) => {
327            if args.json {
328                writeln!(
329                    out.stdout,
330                    "{}",
331                    serde_json::json!({
332                        "status": "error",
333                        "since": since,
334                        "dir": dir.display().to_string(),
335                        "error": e.to_string(),
336                    })
337                )?;
338            } else {
339                writeln!(
340                    out.stderr,
341                    "forensic verify error: {e} (dir={})",
342                    dir.display()
343                )?;
344            }
345            return Ok(2);
346        }
347    };
348
349    if let Some(failure) = &report.first_failure {
350        if args.json {
351            writeln!(
352                out.stdout,
353                "{}",
354                serde_json::json!({
355                    "status": "fail",
356                    (field_names::TOTAL_LINES): report.total_lines,
357                    "unsigned_lines": report.unsigned_lines,
358                    "failure": {
359                        "file": failure.file.display().to_string(),
360                        "line_number": failure.line_number,
361                        "kind": format!("{:?}", failure.kind),
362                        "detail": failure.detail,
363                    },
364                    "since": since,
365                    "dir": dir.display().to_string(),
366                })
367            )?;
368        } else {
369            writeln!(
370                out.stderr,
371                "forensic verify FAIL at {}:{} — {:?}: {}",
372                failure.file.display(),
373                failure.line_number,
374                failure.kind,
375                failure.detail
376            )?;
377        }
378        return Ok(2);
379    }
380
381    if args.json {
382        writeln!(
383            out.stdout,
384            "{}",
385            serde_json::json!({
386                "status": "ok",
387                (field_names::TOTAL_LINES): report.total_lines,
388                "unsigned_lines": report.unsigned_lines,
389                "since": since,
390                "dir": dir.display().to_string(),
391            })
392        )?;
393    } else {
394        writeln!(
395            out.stdout,
396            "forensic verify OK: {} line(s) verified since {} ({} unsigned) at {}",
397            report.total_lines,
398            since,
399            report.unsigned_lines,
400            dir.display()
401        )?;
402    }
403    Ok(0)
404}
405
406fn run_tail(
407    args: &TailArgs,
408    cli_audit_dir: Option<&std::path::Path>,
409    app_config: &AppConfig,
410    out: &mut CliOutput<'_>,
411) -> Result<i32> {
412    let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
413    if !path.exists() {
414        return Ok(0);
415    }
416    let f = fs::File::open(&path)?;
417    let buf = BufReader::new(f);
418    let mut keep: Vec<AuditEvent> = Vec::new();
419    for line in buf.lines() {
420        let line = line?;
421        if line.trim().is_empty() {
422            continue;
423        }
424        let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) else {
425            continue;
426        };
427        if let Some(actor) = &args.actor
428            && !ev.actor.agent_id.contains(actor)
429        {
430            continue;
431        }
432        if let Some(ns) = &args.namespace
433            && ev.target.namespace != *ns
434        {
435            continue;
436        }
437        if let Some(action) = &args.action
438            && ev.action.as_str() != action
439        {
440            continue;
441        }
442        keep.push(ev);
443        if keep.len() > args.lines {
444            keep.remove(0);
445        }
446    }
447    let json_format = args.format != "text";
448    for ev in &keep {
449        if json_format {
450            writeln!(out.stdout, "{}", serde_json::to_string(ev)?)?;
451        } else {
452            writeln!(
453                out.stdout,
454                "{} seq={} {} {} ns={} id={} outcome={:?}",
455                ev.timestamp,
456                ev.sequence,
457                ev.actor.agent_id,
458                ev.action.as_str(),
459                ev.target.namespace,
460                ev.target.memory_id,
461                ev.outcome,
462            )?;
463        }
464    }
465    Ok(0)
466}
467
468fn run_path(
469    cli_audit_dir: Option<&std::path::Path>,
470    app_config: &AppConfig,
471    out: &mut CliOutput<'_>,
472) -> Result<i32> {
473    let p = resolve_path(app_config, cli_audit_dir, None);
474    writeln!(out.stdout, "{}", p.display())?;
475    Ok(0)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::audit::{
482        AuditAction as AAct, AuditOutcome, CHAIN_HEAD_PREV_HASH, EventBuilder, actor, target_memory,
483    };
484    use crate::config::AuditConfig;
485    use crate::models::Tier;
486
487    fn write_chained_log(dir: &Path) -> std::path::PathBuf {
488        // Build a 3-line chain by hand using the public API; we use
489        // the audit module's `init` so emit() produces the lines.
490        let path = dir.join("audit.log");
491        // Reset the global sink across test runs by spinning a fresh
492        // process is impossible; fall back to writing the lines
493        // directly.
494        let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
495        let mut buf = String::new();
496        for seq in 1..=3 {
497            let ev = make_event(seq, &prev_hash);
498            prev_hash = ev.self_hash.clone();
499            buf.push_str(&serde_json::to_string(&ev).unwrap());
500            buf.push('\n');
501        }
502        fs::write(&path, buf).unwrap();
503        path
504    }
505
506    fn make_event(seq: u64, prev: &str) -> AuditEvent {
507        let mut ev = AuditEvent {
508            schema_version: crate::audit::SCHEMA_VERSION,
509            timestamp: format!("2026-04-30T00:00:0{seq}+00:00"),
510            sequence: seq,
511            actor: actor("ai:test@host:pid-1", "host_fallback", None),
512            action: AAct::Store,
513            target: target_memory(
514                format!("mem-{seq}"),
515                "ns-x",
516                Some("title".to_string()),
517                Some(Tier::Mid.as_str().to_string()),
518                None,
519            ),
520            outcome: AuditOutcome::Allow,
521            auth: None,
522            session_id: None,
523            request_id: None,
524            error: None,
525            prev_hash: prev.to_string(),
526            self_hash: String::new(),
527        };
528        // Recompute self_hash via the builder helper exposed
529        // through serde round-trip in tests.
530        let canonical = {
531            let mut clone = ev.clone();
532            clone.self_hash.clear();
533            serde_json::to_string(&clone).unwrap()
534        };
535        use sha2::{Digest, Sha256};
536        let mut h = Sha256::new();
537        h.update(canonical.as_bytes());
538        let bytes = h.finalize();
539        let mut s = String::with_capacity(64);
540        for b in bytes.iter() {
541            s.push_str(&format!("{b:02x}"));
542        }
543        ev.self_hash = s;
544        ev
545    }
546
547    #[test]
548    fn audit_verify_subcmd_reports_ok_for_valid_chain() {
549        let tmp = tempfile::tempdir().unwrap();
550        let p = write_chained_log(tmp.path());
551        let cfg = AppConfig {
552            audit: Some(AuditConfig {
553                enabled: Some(true),
554                path: Some(p.to_string_lossy().into_owned()),
555                ..Default::default()
556            }),
557            ..Default::default()
558        };
559        let mut stdout = Vec::new();
560        let mut stderr = Vec::new();
561        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
562        let exit = run_verify(
563            &VerifyArgs {
564                path: Some(p.to_string_lossy().into_owned()),
565                json: true,
566                since: None,
567                forensic_agent_id: None,
568            },
569            None,
570            &cfg,
571            &mut out,
572        )
573        .unwrap();
574        assert_eq!(exit, 0);
575        let s = std::str::from_utf8(&stdout).unwrap();
576        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
577        assert_eq!(v["status"], "ok");
578        assert_eq!(v["total_lines"], 3);
579    }
580
581    #[test]
582    fn audit_verify_subcmd_detects_tampering() {
583        let tmp = tempfile::tempdir().unwrap();
584        let p = write_chained_log(tmp.path());
585        // Corrupt the second line.
586        let mut body = fs::read_to_string(&p).unwrap();
587        body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
588        fs::write(&p, body).unwrap();
589        let cfg = AppConfig::default();
590        let mut stdout = Vec::new();
591        let mut stderr = Vec::new();
592        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
593        let exit = run_verify(
594            &VerifyArgs {
595                path: Some(p.to_string_lossy().into_owned()),
596                json: true,
597                since: None,
598                forensic_agent_id: None,
599            },
600            None,
601            &cfg,
602            &mut out,
603        )
604        .unwrap();
605        assert_eq!(exit, 2, "tampering must produce non-zero exit");
606        let s = std::str::from_utf8(&stdout).unwrap();
607        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
608        assert_eq!(v["status"], "fail");
609    }
610
611    #[test]
612    fn audit_verify_subcmd_missing_log_is_ok() {
613        let tmp = tempfile::tempdir().unwrap();
614        let cfg = AppConfig::default();
615        let mut stdout = Vec::new();
616        let mut stderr = Vec::new();
617        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
618        let exit = run_verify(
619            &VerifyArgs {
620                path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
621                json: false,
622                since: None,
623                forensic_agent_id: None,
624            },
625            None,
626            &cfg,
627            &mut out,
628        )
629        .unwrap();
630        assert_eq!(exit, 0);
631        let s = std::str::from_utf8(&stdout).unwrap();
632        assert!(s.contains("nothing to check"));
633    }
634
635    #[test]
636    fn audit_path_subcmd_prints_resolved_path() {
637        let cfg = AppConfig {
638            audit: Some(AuditConfig {
639                path: Some("/var/log/ai-memory/custom.log".to_string()),
640                ..Default::default()
641            }),
642            ..Default::default()
643        };
644        let mut stdout = Vec::new();
645        let mut stderr = Vec::new();
646        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
647        run_path(None, &cfg, &mut out).unwrap();
648        let s = std::str::from_utf8(&stdout).unwrap();
649        assert!(s.contains("/var/log/ai-memory/custom.log"));
650    }
651
652    #[test]
653    fn audit_path_subcmd_honours_audit_dir_flag() {
654        let tmp = tempfile::tempdir().unwrap();
655        let cfg = AppConfig::default();
656        let mut stdout = Vec::new();
657        let mut stderr = Vec::new();
658        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
659        run_path(Some(tmp.path()), &cfg, &mut out).unwrap();
660        let s = std::str::from_utf8(&stdout).unwrap();
661        assert!(
662            s.contains(tmp.path().to_string_lossy().as_ref()),
663            "expected audit-dir override to surface in `audit path` output: {s}"
664        );
665        assert!(s.contains("audit.log"));
666    }
667
668    // Compile-time guardrail — make sure EventBuilder is visible from
669    // this module (it's the public emit-API).
670    #[allow(dead_code)]
671    fn _builder_is_visible() {
672        let _ = EventBuilder::new(
673            AAct::Store,
674            actor("a", "explicit", None),
675            target_memory("m", "ns", None, None, None),
676        );
677    }
678
679    // ------------------------------------------------------------------
680    // PR-9e coverage uplift (issue #487): exercise the top-level `run`
681    // dispatcher and the `run_tail` body. Pre-existing tests jumped
682    // straight to `run_verify` / `run_path`; the audit dispatcher arm
683    // for `audit tail` had no coverage at all.
684    // ------------------------------------------------------------------
685
686    #[test]
687    fn audit_run_dispatches_to_verify_arm() {
688        let tmp = tempfile::tempdir().unwrap();
689        let p = write_chained_log(tmp.path());
690        let cfg = AppConfig::default();
691        let mut stdout = Vec::new();
692        let mut stderr = Vec::new();
693        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
694        let args = AuditArgs {
695            action: AuditAction::Verify(VerifyArgs {
696                path: Some(p.to_string_lossy().into_owned()),
697                json: true,
698                since: None,
699                forensic_agent_id: None,
700            }),
701            audit_dir: None,
702        };
703        let exit = run(args, &cfg, &mut out).unwrap();
704        assert_eq!(exit, 0);
705        let s = std::str::from_utf8(&stdout).unwrap();
706        assert!(s.contains("\"status\":\"ok\""), "got: {s}");
707    }
708
709    #[test]
710    fn audit_run_dispatches_to_tail_arm() {
711        let tmp = tempfile::tempdir().unwrap();
712        let p = write_chained_log(tmp.path());
713        let cfg = AppConfig::default();
714        let mut stdout = Vec::new();
715        let mut stderr = Vec::new();
716        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
717        let args = AuditArgs {
718            action: AuditAction::Tail(TailArgs {
719                path: Some(p.to_string_lossy().into_owned()),
720                lines: 10,
721                actor: None,
722                namespace: None,
723                action: None,
724                format: "json".to_string(),
725            }),
726            audit_dir: None,
727        };
728        let exit = run(args, &cfg, &mut out).unwrap();
729        assert_eq!(exit, 0);
730        let s = std::str::from_utf8(&stdout).unwrap();
731        let count = s.lines().filter(|l| !l.is_empty()).count();
732        assert_eq!(count, 3, "expected 3 events from chain, got {count}: {s}");
733    }
734
735    #[test]
736    fn audit_run_dispatches_to_path_arm() {
737        let cfg = AppConfig {
738            audit: Some(AuditConfig {
739                path: Some("/var/log/ai-memory/from-run.log".to_string()),
740                ..Default::default()
741            }),
742            ..Default::default()
743        };
744        let mut stdout = Vec::new();
745        let mut stderr = Vec::new();
746        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
747        let args = AuditArgs {
748            action: AuditAction::Path,
749            audit_dir: None,
750        };
751        let exit = run(args, &cfg, &mut out).unwrap();
752        assert_eq!(exit, 0);
753        let s = std::str::from_utf8(&stdout).unwrap();
754        assert!(s.contains("from-run.log"), "got: {s}");
755    }
756
757    #[test]
758    fn audit_tail_subcmd_returns_last_n_events_in_text_format() {
759        let tmp = tempfile::tempdir().unwrap();
760        let p = write_chained_log(tmp.path());
761        let cfg = AppConfig::default();
762        let mut stdout = Vec::new();
763        let mut stderr = Vec::new();
764        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
765        let exit = run_tail(
766            &TailArgs {
767                path: Some(p.to_string_lossy().into_owned()),
768                lines: 2,
769                actor: None,
770                namespace: None,
771                action: None,
772                format: "text".to_string(),
773            },
774            None,
775            &cfg,
776            &mut out,
777        )
778        .unwrap();
779        assert_eq!(exit, 0);
780        let s = std::str::from_utf8(&stdout).unwrap();
781        // Text format includes "seq=" prefix per line.
782        assert!(s.contains("seq="), "expected text format: {s}");
783        let count = s.lines().filter(|l| !l.is_empty()).count();
784        assert_eq!(count, 2, "lines arg must cap output at 2: {s}");
785    }
786
787    #[test]
788    fn audit_tail_subcmd_emits_json_by_default() {
789        let tmp = tempfile::tempdir().unwrap();
790        let p = write_chained_log(tmp.path());
791        let cfg = AppConfig::default();
792        let mut stdout = Vec::new();
793        let mut stderr = Vec::new();
794        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
795        let exit = run_tail(
796            &TailArgs {
797                path: Some(p.to_string_lossy().into_owned()),
798                lines: 50,
799                actor: None,
800                namespace: None,
801                action: None,
802                format: "json".to_string(),
803            },
804            None,
805            &cfg,
806            &mut out,
807        )
808        .unwrap();
809        assert_eq!(exit, 0);
810        let s = std::str::from_utf8(&stdout).unwrap();
811        // First line must parse as JSON.
812        let first = s.lines().next().expect("at least one line");
813        let v: serde_json::Value = serde_json::from_str(first).expect("json");
814        assert_eq!(v["schema_version"], 1);
815        assert!(v.get("self_hash").is_some());
816    }
817
818    #[test]
819    fn audit_tail_subcmd_filters_by_actor() {
820        let tmp = tempfile::tempdir().unwrap();
821        let p = write_chained_log(tmp.path());
822        let cfg = AppConfig::default();
823        let mut stdout = Vec::new();
824        let mut stderr = Vec::new();
825        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
826        let exit = run_tail(
827            &TailArgs {
828                path: Some(p.to_string_lossy().into_owned()),
829                lines: 50,
830                // Filter that does not match (write_chained_log uses
831                // "ai:test@host:pid-1") — every event must be dropped.
832                actor: Some("nope-not-in-log".to_string()),
833                namespace: None,
834                action: None,
835                format: "json".to_string(),
836            },
837            None,
838            &cfg,
839            &mut out,
840        )
841        .unwrap();
842        assert_eq!(exit, 0);
843        let s = std::str::from_utf8(&stdout).unwrap();
844        assert!(s.is_empty(), "actor filter must drop all events: {s}");
845    }
846
847    #[test]
848    fn audit_tail_subcmd_filters_by_namespace() {
849        let tmp = tempfile::tempdir().unwrap();
850        let p = write_chained_log(tmp.path());
851        let cfg = AppConfig::default();
852        let mut stdout = Vec::new();
853        let mut stderr = Vec::new();
854        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
855        let exit = run_tail(
856            &TailArgs {
857                path: Some(p.to_string_lossy().into_owned()),
858                lines: 50,
859                actor: None,
860                // Mismatched namespace — events use "ns-x" exactly.
861                namespace: Some("not-ns-x".to_string()),
862                action: None,
863                format: "json".to_string(),
864            },
865            None,
866            &cfg,
867            &mut out,
868        )
869        .unwrap();
870        assert_eq!(exit, 0);
871        assert!(stdout.is_empty(), "namespace filter must drop everything");
872    }
873
874    #[test]
875    fn audit_tail_subcmd_filters_by_action_string() {
876        let tmp = tempfile::tempdir().unwrap();
877        let p = write_chained_log(tmp.path());
878        let cfg = AppConfig::default();
879        let mut stdout = Vec::new();
880        let mut stderr = Vec::new();
881        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
882        // The chained log uses Store actions. Filter on "delete" — drop them.
883        let exit = run_tail(
884            &TailArgs {
885                path: Some(p.to_string_lossy().into_owned()),
886                lines: 50,
887                actor: None,
888                namespace: None,
889                action: Some("delete".to_string()),
890                format: "json".to_string(),
891            },
892            None,
893            &cfg,
894            &mut out,
895        )
896        .unwrap();
897        assert_eq!(exit, 0);
898        assert!(
899            stdout.is_empty(),
900            "action=delete must drop all store events"
901        );
902    }
903
904    #[test]
905    fn audit_tail_subcmd_returns_zero_when_log_missing() {
906        let tmp = tempfile::tempdir().unwrap();
907        let cfg = AppConfig::default();
908        let mut stdout = Vec::new();
909        let mut stderr = Vec::new();
910        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
911        let exit = run_tail(
912            &TailArgs {
913                path: Some(
914                    tmp.path()
915                        .join("does-not-exist.log")
916                        .to_string_lossy()
917                        .into_owned(),
918                ),
919                lines: 50,
920                actor: None,
921                namespace: None,
922                action: None,
923                format: "json".to_string(),
924            },
925            None,
926            &cfg,
927            &mut out,
928        )
929        .unwrap();
930        assert_eq!(exit, 0);
931        assert!(stdout.is_empty());
932    }
933
934    #[test]
935    fn audit_tail_subcmd_skips_malformed_lines() {
936        let tmp = tempfile::tempdir().unwrap();
937        let p = write_chained_log(tmp.path());
938        // Append a malformed line; tail must skip it (continue) and
939        // still emit the valid 3 events.
940        let mut body = fs::read_to_string(&p).unwrap();
941        body.push_str("not-valid-json\n\n");
942        fs::write(&p, body).unwrap();
943        let cfg = AppConfig::default();
944        let mut stdout = Vec::new();
945        let mut stderr = Vec::new();
946        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
947        let exit = run_tail(
948            &TailArgs {
949                path: Some(p.to_string_lossy().into_owned()),
950                lines: 50,
951                actor: None,
952                namespace: None,
953                action: None,
954                format: "json".to_string(),
955            },
956            None,
957            &cfg,
958            &mut out,
959        )
960        .unwrap();
961        assert_eq!(exit, 0);
962        let s = std::str::from_utf8(&stdout).unwrap();
963        let count = s.lines().filter(|l| !l.is_empty()).count();
964        assert_eq!(
965            count, 3,
966            "must skip malformed line and keep the 3 good events"
967        );
968    }
969
970    #[test]
971    fn audit_verify_subcmd_missing_log_emits_json_when_flag_set() {
972        let tmp = tempfile::tempdir().unwrap();
973        let cfg = AppConfig::default();
974        let mut stdout = Vec::new();
975        let mut stderr = Vec::new();
976        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
977        let exit = run_verify(
978            &VerifyArgs {
979                path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
980                // JSON-format the missing-log response: exercises the
981                // `args.json` branch of the missing-log early return.
982                json: true,
983                since: None,
984                forensic_agent_id: None,
985            },
986            None,
987            &cfg,
988            &mut out,
989        )
990        .unwrap();
991        assert_eq!(exit, 0);
992        let s = std::str::from_utf8(&stdout).unwrap();
993        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
994        assert_eq!(v["status"], "ok");
995        assert_eq!(v["total_lines"], 0);
996        assert!(v["note"].as_str().unwrap().contains("does not exist"));
997    }
998
999    #[test]
1000    fn audit_verify_subcmd_text_failure_writes_to_stderr() {
1001        let tmp = tempfile::tempdir().unwrap();
1002        let p = write_chained_log(tmp.path());
1003        // Tamper to force a verify failure.
1004        let mut body = fs::read_to_string(&p).unwrap();
1005        body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
1006        fs::write(&p, body).unwrap();
1007        let cfg = AppConfig::default();
1008        let mut stdout = Vec::new();
1009        let mut stderr = Vec::new();
1010        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1011        let exit = run_verify(
1012            &VerifyArgs {
1013                path: Some(p.to_string_lossy().into_owned()),
1014                // text path: writes failure to stderr instead of stdout
1015                json: false,
1016                since: None,
1017                forensic_agent_id: None,
1018            },
1019            None,
1020            &cfg,
1021            &mut out,
1022        )
1023        .unwrap();
1024        assert_eq!(exit, 2);
1025        let serr = std::str::from_utf8(&stderr).unwrap();
1026        assert!(
1027            serr.contains("audit verify FAIL"),
1028            "expected text-format failure on stderr: {serr}"
1029        );
1030    }
1031
1032    #[test]
1033    fn audit_verify_subcmd_text_success_writes_to_stdout() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let p = write_chained_log(tmp.path());
1036        let cfg = AppConfig::default();
1037        let mut stdout = Vec::new();
1038        let mut stderr = Vec::new();
1039        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1040        let exit = run_verify(
1041            &VerifyArgs {
1042                path: Some(p.to_string_lossy().into_owned()),
1043                // text-format success path
1044                json: false,
1045                since: None,
1046                forensic_agent_id: None,
1047            },
1048            None,
1049            &cfg,
1050            &mut out,
1051        )
1052        .unwrap();
1053        assert_eq!(exit, 0);
1054        let s = std::str::from_utf8(&stdout).unwrap();
1055        assert!(s.contains("audit verify OK"), "got: {s}");
1056        assert!(s.contains("3 line(s) verified"));
1057    }
1058
1059    // ---- v0.6.4-009 — `audit show` SQLite audit_log subcommand ----
1060
1061    fn show_args(json: bool, agent_id: Option<&str>) -> ShowArgs {
1062        ShowArgs {
1063            capability_expansions: false,
1064            agent_id: agent_id.map(str::to_string),
1065            limit: 50,
1066            json,
1067        }
1068    }
1069
1070    fn cfg_for_db(p: &std::path::Path) -> AppConfig {
1071        AppConfig {
1072            db: Some(p.to_string_lossy().into_owned()),
1073            ..AppConfig::default()
1074        }
1075    }
1076
1077    #[test]
1078    fn audit_show_emits_no_rows_message_on_empty_table() {
1079        let tmp = tempfile::NamedTempFile::new().unwrap();
1080        let cfg = cfg_for_db(tmp.path());
1081        let mut stdout = Vec::new();
1082        let mut stderr = Vec::new();
1083        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1084        let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
1085        assert_eq!(exit, 0);
1086        let s = std::str::from_utf8(&stdout).unwrap();
1087        assert!(s.contains("audit_log: no rows"), "got: {s}");
1088    }
1089
1090    #[test]
1091    fn audit_show_renders_grant_and_deny_rows_in_text_format() {
1092        let tmp = tempfile::NamedTempFile::new().unwrap();
1093        let cfg = cfg_for_db(tmp.path());
1094        let conn = crate::db::open(tmp.path()).unwrap();
1095        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
1096        crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
1097        drop(conn);
1098
1099        let mut stdout = Vec::new();
1100        let mut stderr = Vec::new();
1101        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1102        let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
1103        assert_eq!(exit, 0);
1104        let s = std::str::from_utf8(&stdout).unwrap();
1105        assert!(s.contains("ALLOW"), "missing ALLOW header in: {s}");
1106        assert!(s.contains("DENY"), "missing DENY header in: {s}");
1107        assert!(s.contains("alice"));
1108        assert!(s.contains("bob"));
1109        assert!(s.contains("graph"));
1110        assert!(s.contains("power"));
1111    }
1112
1113    #[test]
1114    fn audit_show_emits_valid_json_when_flag_set() {
1115        let tmp = tempfile::NamedTempFile::new().unwrap();
1116        let cfg = cfg_for_db(tmp.path());
1117        let conn = crate::db::open(tmp.path()).unwrap();
1118        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
1119        drop(conn);
1120
1121        let mut stdout = Vec::new();
1122        let mut stderr = Vec::new();
1123        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1124        let exit = run_show(&show_args(true, None), &cfg, &mut out).unwrap();
1125        assert_eq!(exit, 0);
1126        let s = std::str::from_utf8(&stdout).unwrap();
1127        let v: serde_json::Value = serde_json::from_str(s).expect("--json must emit valid JSON");
1128        let arr = v.as_array().unwrap();
1129        assert_eq!(arr.len(), 1);
1130        assert_eq!(arr[0]["agent_id"], "alice");
1131        assert_eq!(arr[0]["requested_family"], "graph");
1132        assert_eq!(arr[0]["granted"], true);
1133        assert_eq!(arr[0]["event_type"], "capability_expansion");
1134    }
1135
1136    #[test]
1137    fn audit_show_filters_by_agent_id() {
1138        let tmp = tempfile::NamedTempFile::new().unwrap();
1139        let cfg = cfg_for_db(tmp.path());
1140        let conn = crate::db::open(tmp.path()).unwrap();
1141        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
1142        crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
1143        drop(conn);
1144
1145        let mut stdout = Vec::new();
1146        let mut stderr = Vec::new();
1147        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1148        let exit = run_show(&show_args(true, Some("alice")), &cfg, &mut out).unwrap();
1149        assert_eq!(exit, 0);
1150        let s = std::str::from_utf8(&stdout).unwrap();
1151        let v: serde_json::Value = serde_json::from_str(s).unwrap();
1152        let arr = v.as_array().unwrap();
1153        assert_eq!(arr.len(), 1, "filter should leave only alice rows");
1154        assert_eq!(arr[0]["agent_id"], "alice");
1155    }
1156
1157    #[test]
1158    fn audit_run_dispatches_to_show_arm() {
1159        let tmp = tempfile::NamedTempFile::new().unwrap();
1160        let cfg = cfg_for_db(tmp.path());
1161        let mut stdout = Vec::new();
1162        let mut stderr = Vec::new();
1163        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1164        let args = AuditArgs {
1165            action: AuditAction::Show(show_args(false, None)),
1166            audit_dir: None,
1167        };
1168        let exit = run(args, &cfg, &mut out).unwrap();
1169        assert_eq!(exit, 0);
1170        let s = std::str::from_utf8(&stdout).unwrap();
1171        assert!(s.contains("audit_log"), "got: {s}");
1172    }
1173
1174    // ------------------------------------------------------------------
1175    // Coverage-uplift block (2026-05-19): exercise `run_forensic_verify`
1176    // dispatch (lines 215-216, 295-410) by hand-writing an empty
1177    // forensic directory + invoking `audit verify --since`. The substrate
1178    // helper `crate::governance::audit::verify_since` is already covered
1179    // by `governance::audit::tests`; the CLI wrapper's distinct paths
1180    // are dispatch + envelope/render arms.
1181    // ------------------------------------------------------------------
1182
1183    #[test]
1184    fn audit_verify_with_since_dispatches_to_forensic_verify_json() {
1185        let tmp = tempfile::tempdir().unwrap();
1186        // Audit dir with NO forensic files — verify_since walks an
1187        // empty dir and returns Ok(report with total_lines=0, no
1188        // failures). The dispatch arm covers lines 215-216, 295-309,
1189        // 321-323, 380-391.
1190        let cfg = AppConfig::default();
1191        let mut stdout = Vec::new();
1192        let mut stderr = Vec::new();
1193        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1194        // Pass `path` pointed at a (non-existent) audit.log file
1195        // INSIDE the tempdir. resolve_path strips to the parent (the
1196        // tempdir) as the forensic-files directory.
1197        let exit = run_verify(
1198            &VerifyArgs {
1199                path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
1200                json: true,
1201                since: Some("2026-01-01".into()),
1202                forensic_agent_id: Some("ai:nobody-test".into()),
1203            },
1204            None,
1205            &cfg,
1206            &mut out,
1207        )
1208        .unwrap();
1209        assert_eq!(exit, 0);
1210        let s = std::str::from_utf8(&stdout).unwrap();
1211        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
1212        assert_eq!(v["status"], "ok");
1213        assert_eq!(v["total_lines"], 0);
1214        assert_eq!(v["since"], "2026-01-01");
1215    }
1216
1217    #[test]
1218    fn audit_verify_with_since_human_render_emits_summary_line() {
1219        // Non-JSON path through run_forensic_verify when the report
1220        // is OK and contains zero rows (no files exist). Covers
1221        // lines 392-401.
1222        let tmp = tempfile::tempdir().unwrap();
1223        let cfg = AppConfig::default();
1224        let mut stdout = Vec::new();
1225        let mut stderr = Vec::new();
1226        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1227        let exit = run_verify(
1228            &VerifyArgs {
1229                path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
1230                json: false,
1231                since: Some("2026-01-01".into()),
1232                forensic_agent_id: None,
1233            },
1234            None,
1235            &cfg,
1236            &mut out,
1237        )
1238        .unwrap();
1239        assert_eq!(exit, 0);
1240        let s = std::str::from_utf8(&stdout).unwrap();
1241        // Human render starts with "forensic verify OK:".
1242        assert!(s.starts_with("forensic verify OK:"), "got: {s}");
1243        assert!(s.contains("0 line(s)"));
1244        assert!(s.contains("since 2026-01-01"));
1245    }
1246
1247    #[test]
1248    fn audit_verify_with_since_returns_error_on_unparseable_date() {
1249        // Drives lines 323-345 — verify_since errors out on bad
1250        // date, the dispatch wraps the error into exit=2 + the
1251        // appropriate envelope.
1252        let tmp = tempfile::tempdir().unwrap();
1253        let cfg = AppConfig::default();
1254        let mut stdout = Vec::new();
1255        let mut stderr = Vec::new();
1256        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1257        let exit = run_verify(
1258            &VerifyArgs {
1259                path: Some(tmp.path().join("audit.log").to_string_lossy().into_owned()),
1260                json: true,
1261                since: Some("not-a-date".into()),
1262                forensic_agent_id: None,
1263            },
1264            None,
1265            &cfg,
1266            &mut out,
1267        )
1268        .unwrap();
1269        assert_eq!(exit, 2);
1270        let s = std::str::from_utf8(&stdout).unwrap();
1271        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
1272        assert_eq!(v["status"], "error");
1273        assert!(v["error"].as_str().unwrap().contains("parsing --since"));
1274    }
1275}