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 std::fs;
16use std::io::{BufRead, BufReader};
17#[cfg(test)]
18use std::path::Path;
19
20use anyhow::Result;
21use clap::{Args, Subcommand};
22
23use crate::audit::{
24    AuditEvent, resolve_audit_path, resolve_audit_path_with_override, verify_chain,
25};
26use crate::cli::CliOutput;
27use crate::config::AppConfig;
28
29#[derive(Args)]
30pub struct AuditArgs {
31    #[command(subcommand)]
32    pub action: AuditAction,
33    /// Override the audit log directory. Highest-priority layer in the
34    /// resolution ladder (CLI > `AI_MEMORY_AUDIT_DIR` > `[audit] path`
35    /// in config.toml > platform default). Refuses world-writable
36    /// directories — see `docs/security/audit-trail.md`.
37    #[arg(long, global = true, value_name = "PATH")]
38    pub audit_dir: Option<std::path::PathBuf>,
39}
40
41#[derive(Subcommand)]
42pub enum AuditAction {
43    /// Verify the hash chain. Exits 0 on success, 2 on mismatch.
44    Verify(VerifyArgs),
45    /// Print the most recent N events (default 50).
46    Tail(TailArgs),
47    /// Print the resolved audit log path.
48    Path,
49    /// v0.6.4-009 — list rows from the in-DB `audit_log` table
50    /// (capability expansions, future event types). Reads the SQLite
51    /// audit_log table; orthogonal to the file-based hash-chained
52    /// trail surfaced by `tail` / `verify`.
53    Show(ShowArgs),
54}
55
56#[derive(Args)]
57pub struct ShowArgs {
58    /// Restrict to capability-expansion events (today the only event
59    /// type written to audit_log; reserves the option for future
60    /// event types).
61    #[arg(long)]
62    pub capability_expansions: bool,
63    /// Filter by exact agent_id match.
64    #[arg(long, value_name = "AGENT_ID")]
65    pub agent_id: Option<String>,
66    /// Maximum rows to return (default 50, max 10000).
67    #[arg(long, default_value_t = 50)]
68    pub limit: usize,
69    /// Emit JSON instead of human-readable text.
70    #[arg(long)]
71    pub json: bool,
72}
73
74#[derive(Args)]
75pub struct VerifyArgs {
76    /// Override the configured audit log path.
77    #[arg(long)]
78    pub path: Option<String>,
79    /// Emit a JSON report instead of text.
80    #[arg(long, default_value_t = false)]
81    pub json: bool,
82}
83
84#[derive(Args)]
85pub struct TailArgs {
86    /// Override the configured audit log path.
87    #[arg(long)]
88    pub path: Option<String>,
89    /// Number of trailing lines to print. Default 50.
90    #[arg(long, default_value_t = 50)]
91    pub lines: usize,
92    /// Filter by `actor.agent_id`.
93    #[arg(long)]
94    pub actor: Option<String>,
95    /// Filter by `target.namespace`.
96    #[arg(long)]
97    pub namespace: Option<String>,
98    /// Filter by `action`.
99    #[arg(long)]
100    pub action: Option<String>,
101    /// Output format: `json` (default) or `text`.
102    #[arg(long, default_value = "json")]
103    pub format: String,
104}
105
106/// `ai-memory audit` entry point. Returns the desired process exit
107/// code so the caller can surface a non-zero status from the top-level
108/// dispatch without panicking.
109pub fn run(args: AuditArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
110    let audit_dir = args.audit_dir.clone();
111    match args.action {
112        AuditAction::Verify(v) => run_verify(&v, audit_dir.as_deref(), app_config, out),
113        AuditAction::Tail(t) => run_tail(&t, audit_dir.as_deref(), app_config, out),
114        AuditAction::Path => run_path(audit_dir.as_deref(), app_config, out),
115        AuditAction::Show(s) => run_show(&s, app_config, out),
116    }
117}
118
119/// v0.6.4-009 — print rows from the `audit_log` SQLite table.
120fn run_show(args: &ShowArgs, app_config: &AppConfig, out: &mut CliOutput<'_>) -> Result<i32> {
121    let db_path = app_config.effective_db(std::path::Path::new("ai-memory.db"));
122    let conn = crate::db::open(&db_path)?;
123    let rows = crate::db::list_capability_expansions(&conn, args.limit, args.agent_id.as_deref())?;
124    if args.json {
125        let payload: Vec<serde_json::Value> = rows
126            .iter()
127            .map(|r| {
128                serde_json::json!({
129                    "id": r.id,
130                    "agent_id": r.agent_id,
131                    "event_type": r.event_type,
132                    "requested_family": r.requested_family,
133                    "granted": r.granted,
134                    "attestation_tier": r.attestation_tier,
135                    "timestamp": r.timestamp,
136                })
137            })
138            .collect();
139        writeln!(out.stdout, "{}", serde_json::to_string_pretty(&payload)?)?;
140        return Ok(0);
141    }
142    if rows.is_empty() {
143        writeln!(out.stdout, "audit_log: no rows")?;
144        return Ok(0);
145    }
146    writeln!(
147        out.stdout,
148        "{:<25} {:<7} {:<12} {:<32} {:<6}",
149        "timestamp", "granted", "family", "agent_id", "event"
150    )?;
151    for r in &rows {
152        let aid = r.agent_id.as_deref().unwrap_or("<anonymous>");
153        let fam = r.requested_family.as_deref().unwrap_or("-");
154        writeln!(
155            out.stdout,
156            "{:<25} {:<7} {:<12} {:<32} {:<6}",
157            r.timestamp,
158            if r.granted { "ALLOW" } else { "DENY" },
159            fam,
160            aid,
161            r.event_type
162        )?;
163    }
164    Ok(0)
165}
166
167/// Resolve the audit log path honouring (in order): explicit per-subcommand
168/// `--path` (legacy `VerifyArgs.path` / `TailArgs.path`), the global
169/// `--audit-dir` flag, `AI_MEMORY_AUDIT_DIR`, `[audit] path` in
170/// config.toml, and the platform default. Falls back to the loose
171/// `resolve_audit_path` if any layer above produces an error so the
172/// `audit path` subcommand can still print a useful answer when
173/// `--audit-dir` is mistyped.
174fn resolve_path(
175    app_config: &AppConfig,
176    cli_audit_dir: Option<&std::path::Path>,
177    explicit_per_cmd: Option<&str>,
178) -> std::path::PathBuf {
179    if let Some(p) = explicit_per_cmd {
180        return std::path::PathBuf::from(crate::audit::expand_tilde(p));
181    }
182    let cfg = app_config.effective_audit();
183    if let Ok((p, _src)) = resolve_audit_path_with_override(cli_audit_dir, &cfg) {
184        return p;
185    }
186    resolve_audit_path(&cfg)
187}
188
189fn run_verify(
190    args: &VerifyArgs,
191    cli_audit_dir: Option<&std::path::Path>,
192    app_config: &AppConfig,
193    out: &mut CliOutput<'_>,
194) -> Result<i32> {
195    let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
196    if !path.exists() {
197        if args.json {
198            writeln!(
199                out.stdout,
200                "{}",
201                serde_json::json!({
202                    "status": "ok",
203                    "total_lines": 0,
204                    "note": "audit log does not exist (audit may be disabled)",
205                    "path": path.display().to_string(),
206                })
207            )?;
208        } else {
209            writeln!(
210                out.stdout,
211                "audit verify: log not present at {} — nothing to check",
212                path.display()
213            )?;
214        }
215        return Ok(0);
216    }
217    let report = verify_chain(&path)?;
218    if let Some(failure) = &report.first_failure {
219        if args.json {
220            writeln!(
221                out.stdout,
222                "{}",
223                serde_json::json!({
224                    "status": "fail",
225                    "total_lines": report.total_lines,
226                    "failure": {
227                        "line_number": failure.line_number,
228                        "kind": format!("{:?}", failure.kind),
229                        "detail": failure.detail,
230                    },
231                    "path": path.display().to_string(),
232                })
233            )?;
234        } else {
235            writeln!(
236                out.stderr,
237                "audit verify FAIL at line {}: {:?} — {}",
238                failure.line_number, failure.kind, failure.detail
239            )?;
240        }
241        return Ok(2);
242    }
243    if args.json {
244        writeln!(
245            out.stdout,
246            "{}",
247            serde_json::json!({
248                "status": "ok",
249                "total_lines": report.total_lines,
250                "path": path.display().to_string(),
251            })
252        )?;
253    } else {
254        writeln!(
255            out.stdout,
256            "audit verify OK: {} line(s) verified at {}",
257            report.total_lines,
258            path.display()
259        )?;
260    }
261    Ok(0)
262}
263
264fn run_tail(
265    args: &TailArgs,
266    cli_audit_dir: Option<&std::path::Path>,
267    app_config: &AppConfig,
268    out: &mut CliOutput<'_>,
269) -> Result<i32> {
270    let path = resolve_path(app_config, cli_audit_dir, args.path.as_deref());
271    if !path.exists() {
272        return Ok(0);
273    }
274    let f = fs::File::open(&path)?;
275    let buf = BufReader::new(f);
276    let mut keep: Vec<AuditEvent> = Vec::new();
277    for line in buf.lines() {
278        let line = line?;
279        if line.trim().is_empty() {
280            continue;
281        }
282        let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) else {
283            continue;
284        };
285        if let Some(actor) = &args.actor
286            && !ev.actor.agent_id.contains(actor)
287        {
288            continue;
289        }
290        if let Some(ns) = &args.namespace
291            && ev.target.namespace != *ns
292        {
293            continue;
294        }
295        if let Some(action) = &args.action
296            && ev.action.as_str() != action
297        {
298            continue;
299        }
300        keep.push(ev);
301        if keep.len() > args.lines {
302            keep.remove(0);
303        }
304    }
305    let json_format = args.format != "text";
306    for ev in &keep {
307        if json_format {
308            writeln!(out.stdout, "{}", serde_json::to_string(ev)?)?;
309        } else {
310            writeln!(
311                out.stdout,
312                "{} seq={} {} {} ns={} id={} outcome={:?}",
313                ev.timestamp,
314                ev.sequence,
315                ev.actor.agent_id,
316                ev.action.as_str(),
317                ev.target.namespace,
318                ev.target.memory_id,
319                ev.outcome,
320            )?;
321        }
322    }
323    Ok(0)
324}
325
326fn run_path(
327    cli_audit_dir: Option<&std::path::Path>,
328    app_config: &AppConfig,
329    out: &mut CliOutput<'_>,
330) -> Result<i32> {
331    let p = resolve_path(app_config, cli_audit_dir, None);
332    writeln!(out.stdout, "{}", p.display())?;
333    Ok(0)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::audit::{
340        AuditAction as AAct, AuditOutcome, CHAIN_HEAD_PREV_HASH, EventBuilder, actor, target_memory,
341    };
342    use crate::config::AuditConfig;
343
344    fn write_chained_log(dir: &Path) -> std::path::PathBuf {
345        // Build a 3-line chain by hand using the public API; we use
346        // the audit module's `init` so emit() produces the lines.
347        let path = dir.join("audit.log");
348        // Reset the global sink across test runs by spinning a fresh
349        // process is impossible; fall back to writing the lines
350        // directly.
351        let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
352        let mut buf = String::new();
353        for seq in 1..=3 {
354            let ev = make_event(seq, &prev_hash);
355            prev_hash = ev.self_hash.clone();
356            buf.push_str(&serde_json::to_string(&ev).unwrap());
357            buf.push('\n');
358        }
359        fs::write(&path, buf).unwrap();
360        path
361    }
362
363    fn make_event(seq: u64, prev: &str) -> AuditEvent {
364        let mut ev = AuditEvent {
365            schema_version: crate::audit::SCHEMA_VERSION,
366            timestamp: format!("2026-04-30T00:00:0{seq}+00:00"),
367            sequence: seq,
368            actor: actor("ai:test@host:pid-1", "host_fallback", None),
369            action: AAct::Store,
370            target: target_memory(
371                format!("mem-{seq}"),
372                "ns-x",
373                Some("title".to_string()),
374                Some("mid".to_string()),
375                None,
376            ),
377            outcome: AuditOutcome::Allow,
378            auth: None,
379            session_id: None,
380            request_id: None,
381            error: None,
382            prev_hash: prev.to_string(),
383            self_hash: String::new(),
384        };
385        // Recompute self_hash via the builder helper exposed
386        // through serde round-trip in tests.
387        let canonical = {
388            let mut clone = ev.clone();
389            clone.self_hash.clear();
390            serde_json::to_string(&clone).unwrap()
391        };
392        use sha2::{Digest, Sha256};
393        let mut h = Sha256::new();
394        h.update(canonical.as_bytes());
395        let bytes = h.finalize();
396        let mut s = String::with_capacity(64);
397        for b in bytes.iter() {
398            s.push_str(&format!("{b:02x}"));
399        }
400        ev.self_hash = s;
401        ev
402    }
403
404    #[test]
405    fn audit_verify_subcmd_reports_ok_for_valid_chain() {
406        let tmp = tempfile::tempdir().unwrap();
407        let p = write_chained_log(tmp.path());
408        let cfg = AppConfig {
409            audit: Some(AuditConfig {
410                enabled: Some(true),
411                path: Some(p.to_string_lossy().into_owned()),
412                ..Default::default()
413            }),
414            ..Default::default()
415        };
416        let mut stdout = Vec::new();
417        let mut stderr = Vec::new();
418        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
419        let exit = run_verify(
420            &VerifyArgs {
421                path: Some(p.to_string_lossy().into_owned()),
422                json: true,
423            },
424            None,
425            &cfg,
426            &mut out,
427        )
428        .unwrap();
429        assert_eq!(exit, 0);
430        let s = std::str::from_utf8(&stdout).unwrap();
431        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
432        assert_eq!(v["status"], "ok");
433        assert_eq!(v["total_lines"], 3);
434    }
435
436    #[test]
437    fn audit_verify_subcmd_detects_tampering() {
438        let tmp = tempfile::tempdir().unwrap();
439        let p = write_chained_log(tmp.path());
440        // Corrupt the second line.
441        let mut body = fs::read_to_string(&p).unwrap();
442        body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
443        fs::write(&p, body).unwrap();
444        let cfg = AppConfig::default();
445        let mut stdout = Vec::new();
446        let mut stderr = Vec::new();
447        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
448        let exit = run_verify(
449            &VerifyArgs {
450                path: Some(p.to_string_lossy().into_owned()),
451                json: true,
452            },
453            None,
454            &cfg,
455            &mut out,
456        )
457        .unwrap();
458        assert_eq!(exit, 2, "tampering must produce non-zero exit");
459        let s = std::str::from_utf8(&stdout).unwrap();
460        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
461        assert_eq!(v["status"], "fail");
462    }
463
464    #[test]
465    fn audit_verify_subcmd_missing_log_is_ok() {
466        let tmp = tempfile::tempdir().unwrap();
467        let cfg = AppConfig::default();
468        let mut stdout = Vec::new();
469        let mut stderr = Vec::new();
470        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
471        let exit = run_verify(
472            &VerifyArgs {
473                path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
474                json: false,
475            },
476            None,
477            &cfg,
478            &mut out,
479        )
480        .unwrap();
481        assert_eq!(exit, 0);
482        let s = std::str::from_utf8(&stdout).unwrap();
483        assert!(s.contains("nothing to check"));
484    }
485
486    #[test]
487    fn audit_path_subcmd_prints_resolved_path() {
488        let cfg = AppConfig {
489            audit: Some(AuditConfig {
490                path: Some("/var/log/ai-memory/custom.log".to_string()),
491                ..Default::default()
492            }),
493            ..Default::default()
494        };
495        let mut stdout = Vec::new();
496        let mut stderr = Vec::new();
497        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
498        run_path(None, &cfg, &mut out).unwrap();
499        let s = std::str::from_utf8(&stdout).unwrap();
500        assert!(s.contains("/var/log/ai-memory/custom.log"));
501    }
502
503    #[test]
504    fn audit_path_subcmd_honours_audit_dir_flag() {
505        let tmp = tempfile::tempdir().unwrap();
506        let cfg = AppConfig::default();
507        let mut stdout = Vec::new();
508        let mut stderr = Vec::new();
509        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
510        run_path(Some(tmp.path()), &cfg, &mut out).unwrap();
511        let s = std::str::from_utf8(&stdout).unwrap();
512        assert!(
513            s.contains(tmp.path().to_string_lossy().as_ref()),
514            "expected audit-dir override to surface in `audit path` output: {s}"
515        );
516        assert!(s.contains("audit.log"));
517    }
518
519    // Compile-time guardrail — make sure EventBuilder is visible from
520    // this module (it's the public emit-API).
521    #[allow(dead_code)]
522    fn _builder_is_visible() {
523        let _ = EventBuilder::new(
524            AAct::Store,
525            actor("a", "explicit", None),
526            target_memory("m", "ns", None, None, None),
527        );
528    }
529
530    // ------------------------------------------------------------------
531    // PR-9e coverage uplift (issue #487): exercise the top-level `run`
532    // dispatcher and the `run_tail` body. Pre-existing tests jumped
533    // straight to `run_verify` / `run_path`; the audit dispatcher arm
534    // for `audit tail` had no coverage at all.
535    // ------------------------------------------------------------------
536
537    #[test]
538    fn audit_run_dispatches_to_verify_arm() {
539        let tmp = tempfile::tempdir().unwrap();
540        let p = write_chained_log(tmp.path());
541        let cfg = AppConfig::default();
542        let mut stdout = Vec::new();
543        let mut stderr = Vec::new();
544        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
545        let args = AuditArgs {
546            action: AuditAction::Verify(VerifyArgs {
547                path: Some(p.to_string_lossy().into_owned()),
548                json: true,
549            }),
550            audit_dir: None,
551        };
552        let exit = run(args, &cfg, &mut out).unwrap();
553        assert_eq!(exit, 0);
554        let s = std::str::from_utf8(&stdout).unwrap();
555        assert!(s.contains("\"status\":\"ok\""), "got: {s}");
556    }
557
558    #[test]
559    fn audit_run_dispatches_to_tail_arm() {
560        let tmp = tempfile::tempdir().unwrap();
561        let p = write_chained_log(tmp.path());
562        let cfg = AppConfig::default();
563        let mut stdout = Vec::new();
564        let mut stderr = Vec::new();
565        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
566        let args = AuditArgs {
567            action: AuditAction::Tail(TailArgs {
568                path: Some(p.to_string_lossy().into_owned()),
569                lines: 10,
570                actor: None,
571                namespace: None,
572                action: None,
573                format: "json".to_string(),
574            }),
575            audit_dir: None,
576        };
577        let exit = run(args, &cfg, &mut out).unwrap();
578        assert_eq!(exit, 0);
579        let s = std::str::from_utf8(&stdout).unwrap();
580        let count = s.lines().filter(|l| !l.is_empty()).count();
581        assert_eq!(count, 3, "expected 3 events from chain, got {count}: {s}");
582    }
583
584    #[test]
585    fn audit_run_dispatches_to_path_arm() {
586        let cfg = AppConfig {
587            audit: Some(AuditConfig {
588                path: Some("/var/log/ai-memory/from-run.log".to_string()),
589                ..Default::default()
590            }),
591            ..Default::default()
592        };
593        let mut stdout = Vec::new();
594        let mut stderr = Vec::new();
595        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
596        let args = AuditArgs {
597            action: AuditAction::Path,
598            audit_dir: None,
599        };
600        let exit = run(args, &cfg, &mut out).unwrap();
601        assert_eq!(exit, 0);
602        let s = std::str::from_utf8(&stdout).unwrap();
603        assert!(s.contains("from-run.log"), "got: {s}");
604    }
605
606    #[test]
607    fn audit_tail_subcmd_returns_last_n_events_in_text_format() {
608        let tmp = tempfile::tempdir().unwrap();
609        let p = write_chained_log(tmp.path());
610        let cfg = AppConfig::default();
611        let mut stdout = Vec::new();
612        let mut stderr = Vec::new();
613        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
614        let exit = run_tail(
615            &TailArgs {
616                path: Some(p.to_string_lossy().into_owned()),
617                lines: 2,
618                actor: None,
619                namespace: None,
620                action: None,
621                format: "text".to_string(),
622            },
623            None,
624            &cfg,
625            &mut out,
626        )
627        .unwrap();
628        assert_eq!(exit, 0);
629        let s = std::str::from_utf8(&stdout).unwrap();
630        // Text format includes "seq=" prefix per line.
631        assert!(s.contains("seq="), "expected text format: {s}");
632        let count = s.lines().filter(|l| !l.is_empty()).count();
633        assert_eq!(count, 2, "lines arg must cap output at 2: {s}");
634    }
635
636    #[test]
637    fn audit_tail_subcmd_emits_json_by_default() {
638        let tmp = tempfile::tempdir().unwrap();
639        let p = write_chained_log(tmp.path());
640        let cfg = AppConfig::default();
641        let mut stdout = Vec::new();
642        let mut stderr = Vec::new();
643        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
644        let exit = run_tail(
645            &TailArgs {
646                path: Some(p.to_string_lossy().into_owned()),
647                lines: 50,
648                actor: None,
649                namespace: None,
650                action: None,
651                format: "json".to_string(),
652            },
653            None,
654            &cfg,
655            &mut out,
656        )
657        .unwrap();
658        assert_eq!(exit, 0);
659        let s = std::str::from_utf8(&stdout).unwrap();
660        // First line must parse as JSON.
661        let first = s.lines().next().expect("at least one line");
662        let v: serde_json::Value = serde_json::from_str(first).expect("json");
663        assert_eq!(v["schema_version"], 1);
664        assert!(v.get("self_hash").is_some());
665    }
666
667    #[test]
668    fn audit_tail_subcmd_filters_by_actor() {
669        let tmp = tempfile::tempdir().unwrap();
670        let p = write_chained_log(tmp.path());
671        let cfg = AppConfig::default();
672        let mut stdout = Vec::new();
673        let mut stderr = Vec::new();
674        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
675        let exit = run_tail(
676            &TailArgs {
677                path: Some(p.to_string_lossy().into_owned()),
678                lines: 50,
679                // Filter that does not match (write_chained_log uses
680                // "ai:test@host:pid-1") — every event must be dropped.
681                actor: Some("nope-not-in-log".to_string()),
682                namespace: None,
683                action: None,
684                format: "json".to_string(),
685            },
686            None,
687            &cfg,
688            &mut out,
689        )
690        .unwrap();
691        assert_eq!(exit, 0);
692        let s = std::str::from_utf8(&stdout).unwrap();
693        assert!(s.is_empty(), "actor filter must drop all events: {s}");
694    }
695
696    #[test]
697    fn audit_tail_subcmd_filters_by_namespace() {
698        let tmp = tempfile::tempdir().unwrap();
699        let p = write_chained_log(tmp.path());
700        let cfg = AppConfig::default();
701        let mut stdout = Vec::new();
702        let mut stderr = Vec::new();
703        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
704        let exit = run_tail(
705            &TailArgs {
706                path: Some(p.to_string_lossy().into_owned()),
707                lines: 50,
708                actor: None,
709                // Mismatched namespace — events use "ns-x" exactly.
710                namespace: Some("not-ns-x".to_string()),
711                action: None,
712                format: "json".to_string(),
713            },
714            None,
715            &cfg,
716            &mut out,
717        )
718        .unwrap();
719        assert_eq!(exit, 0);
720        assert!(stdout.is_empty(), "namespace filter must drop everything");
721    }
722
723    #[test]
724    fn audit_tail_subcmd_filters_by_action_string() {
725        let tmp = tempfile::tempdir().unwrap();
726        let p = write_chained_log(tmp.path());
727        let cfg = AppConfig::default();
728        let mut stdout = Vec::new();
729        let mut stderr = Vec::new();
730        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
731        // The chained log uses Store actions. Filter on "delete" — drop them.
732        let exit = run_tail(
733            &TailArgs {
734                path: Some(p.to_string_lossy().into_owned()),
735                lines: 50,
736                actor: None,
737                namespace: None,
738                action: Some("delete".to_string()),
739                format: "json".to_string(),
740            },
741            None,
742            &cfg,
743            &mut out,
744        )
745        .unwrap();
746        assert_eq!(exit, 0);
747        assert!(
748            stdout.is_empty(),
749            "action=delete must drop all store events"
750        );
751    }
752
753    #[test]
754    fn audit_tail_subcmd_returns_zero_when_log_missing() {
755        let tmp = tempfile::tempdir().unwrap();
756        let cfg = AppConfig::default();
757        let mut stdout = Vec::new();
758        let mut stderr = Vec::new();
759        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
760        let exit = run_tail(
761            &TailArgs {
762                path: Some(
763                    tmp.path()
764                        .join("does-not-exist.log")
765                        .to_string_lossy()
766                        .into_owned(),
767                ),
768                lines: 50,
769                actor: None,
770                namespace: None,
771                action: None,
772                format: "json".to_string(),
773            },
774            None,
775            &cfg,
776            &mut out,
777        )
778        .unwrap();
779        assert_eq!(exit, 0);
780        assert!(stdout.is_empty());
781    }
782
783    #[test]
784    fn audit_tail_subcmd_skips_malformed_lines() {
785        let tmp = tempfile::tempdir().unwrap();
786        let p = write_chained_log(tmp.path());
787        // Append a malformed line; tail must skip it (continue) and
788        // still emit the valid 3 events.
789        let mut body = fs::read_to_string(&p).unwrap();
790        body.push_str("not-valid-json\n\n");
791        fs::write(&p, body).unwrap();
792        let cfg = AppConfig::default();
793        let mut stdout = Vec::new();
794        let mut stderr = Vec::new();
795        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
796        let exit = run_tail(
797            &TailArgs {
798                path: Some(p.to_string_lossy().into_owned()),
799                lines: 50,
800                actor: None,
801                namespace: None,
802                action: None,
803                format: "json".to_string(),
804            },
805            None,
806            &cfg,
807            &mut out,
808        )
809        .unwrap();
810        assert_eq!(exit, 0);
811        let s = std::str::from_utf8(&stdout).unwrap();
812        let count = s.lines().filter(|l| !l.is_empty()).count();
813        assert_eq!(
814            count, 3,
815            "must skip malformed line and keep the 3 good events"
816        );
817    }
818
819    #[test]
820    fn audit_verify_subcmd_missing_log_emits_json_when_flag_set() {
821        let tmp = tempfile::tempdir().unwrap();
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_verify(
827            &VerifyArgs {
828                path: Some(tmp.path().join("nope.log").to_string_lossy().into_owned()),
829                // JSON-format the missing-log response: exercises the
830                // `args.json` branch of the missing-log early return.
831                json: true,
832            },
833            None,
834            &cfg,
835            &mut out,
836        )
837        .unwrap();
838        assert_eq!(exit, 0);
839        let s = std::str::from_utf8(&stdout).unwrap();
840        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
841        assert_eq!(v["status"], "ok");
842        assert_eq!(v["total_lines"], 0);
843        assert!(v["note"].as_str().unwrap().contains("does not exist"));
844    }
845
846    #[test]
847    fn audit_verify_subcmd_text_failure_writes_to_stderr() {
848        let tmp = tempfile::tempdir().unwrap();
849        let p = write_chained_log(tmp.path());
850        // Tamper to force a verify failure.
851        let mut body = fs::read_to_string(&p).unwrap();
852        body = body.replacen("\"sequence\":2", "\"sequence\":99", 1);
853        fs::write(&p, body).unwrap();
854        let cfg = AppConfig::default();
855        let mut stdout = Vec::new();
856        let mut stderr = Vec::new();
857        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
858        let exit = run_verify(
859            &VerifyArgs {
860                path: Some(p.to_string_lossy().into_owned()),
861                // text path: writes failure to stderr instead of stdout
862                json: false,
863            },
864            None,
865            &cfg,
866            &mut out,
867        )
868        .unwrap();
869        assert_eq!(exit, 2);
870        let serr = std::str::from_utf8(&stderr).unwrap();
871        assert!(
872            serr.contains("audit verify FAIL"),
873            "expected text-format failure on stderr: {serr}"
874        );
875    }
876
877    #[test]
878    fn audit_verify_subcmd_text_success_writes_to_stdout() {
879        let tmp = tempfile::tempdir().unwrap();
880        let p = write_chained_log(tmp.path());
881        let cfg = AppConfig::default();
882        let mut stdout = Vec::new();
883        let mut stderr = Vec::new();
884        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
885        let exit = run_verify(
886            &VerifyArgs {
887                path: Some(p.to_string_lossy().into_owned()),
888                // text-format success path
889                json: false,
890            },
891            None,
892            &cfg,
893            &mut out,
894        )
895        .unwrap();
896        assert_eq!(exit, 0);
897        let s = std::str::from_utf8(&stdout).unwrap();
898        assert!(s.contains("audit verify OK"), "got: {s}");
899        assert!(s.contains("3 line(s) verified"));
900    }
901
902    // ---- v0.6.4-009 — `audit show` SQLite audit_log subcommand ----
903
904    fn show_args(json: bool, agent_id: Option<&str>) -> ShowArgs {
905        ShowArgs {
906            capability_expansions: false,
907            agent_id: agent_id.map(str::to_string),
908            limit: 50,
909            json,
910        }
911    }
912
913    fn cfg_for_db(p: &std::path::Path) -> AppConfig {
914        AppConfig {
915            db: Some(p.to_string_lossy().into_owned()),
916            ..AppConfig::default()
917        }
918    }
919
920    #[test]
921    fn audit_show_emits_no_rows_message_on_empty_table() {
922        let tmp = tempfile::NamedTempFile::new().unwrap();
923        let cfg = cfg_for_db(tmp.path());
924        let mut stdout = Vec::new();
925        let mut stderr = Vec::new();
926        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
927        let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
928        assert_eq!(exit, 0);
929        let s = std::str::from_utf8(&stdout).unwrap();
930        assert!(s.contains("audit_log: no rows"), "got: {s}");
931    }
932
933    #[test]
934    fn audit_show_renders_grant_and_deny_rows_in_text_format() {
935        let tmp = tempfile::NamedTempFile::new().unwrap();
936        let cfg = cfg_for_db(tmp.path());
937        let conn = crate::db::open(tmp.path()).unwrap();
938        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
939        crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
940        drop(conn);
941
942        let mut stdout = Vec::new();
943        let mut stderr = Vec::new();
944        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
945        let exit = run_show(&show_args(false, None), &cfg, &mut out).unwrap();
946        assert_eq!(exit, 0);
947        let s = std::str::from_utf8(&stdout).unwrap();
948        assert!(s.contains("ALLOW"), "missing ALLOW header in: {s}");
949        assert!(s.contains("DENY"), "missing DENY header in: {s}");
950        assert!(s.contains("alice"));
951        assert!(s.contains("bob"));
952        assert!(s.contains("graph"));
953        assert!(s.contains("power"));
954    }
955
956    #[test]
957    fn audit_show_emits_valid_json_when_flag_set() {
958        let tmp = tempfile::NamedTempFile::new().unwrap();
959        let cfg = cfg_for_db(tmp.path());
960        let conn = crate::db::open(tmp.path()).unwrap();
961        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
962        drop(conn);
963
964        let mut stdout = Vec::new();
965        let mut stderr = Vec::new();
966        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
967        let exit = run_show(&show_args(true, None), &cfg, &mut out).unwrap();
968        assert_eq!(exit, 0);
969        let s = std::str::from_utf8(&stdout).unwrap();
970        let v: serde_json::Value = serde_json::from_str(s).expect("--json must emit valid JSON");
971        let arr = v.as_array().unwrap();
972        assert_eq!(arr.len(), 1);
973        assert_eq!(arr[0]["agent_id"], "alice");
974        assert_eq!(arr[0]["requested_family"], "graph");
975        assert_eq!(arr[0]["granted"], true);
976        assert_eq!(arr[0]["event_type"], "capability_expansion");
977    }
978
979    #[test]
980    fn audit_show_filters_by_agent_id() {
981        let tmp = tempfile::NamedTempFile::new().unwrap();
982        let cfg = cfg_for_db(tmp.path());
983        let conn = crate::db::open(tmp.path()).unwrap();
984        crate::db::record_capability_expansion(&conn, Some("alice"), "graph", true, None);
985        crate::db::record_capability_expansion(&conn, Some("bob"), "power", false, None);
986        drop(conn);
987
988        let mut stdout = Vec::new();
989        let mut stderr = Vec::new();
990        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
991        let exit = run_show(&show_args(true, Some("alice")), &cfg, &mut out).unwrap();
992        assert_eq!(exit, 0);
993        let s = std::str::from_utf8(&stdout).unwrap();
994        let v: serde_json::Value = serde_json::from_str(s).unwrap();
995        let arr = v.as_array().unwrap();
996        assert_eq!(arr.len(), 1, "filter should leave only alice rows");
997        assert_eq!(arr[0]["agent_id"], "alice");
998    }
999
1000    #[test]
1001    fn audit_run_dispatches_to_show_arm() {
1002        let tmp = tempfile::NamedTempFile::new().unwrap();
1003        let cfg = cfg_for_db(tmp.path());
1004        let mut stdout = Vec::new();
1005        let mut stderr = Vec::new();
1006        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1007        let args = AuditArgs {
1008            action: AuditAction::Show(show_args(false, None)),
1009            audit_dir: None,
1010        };
1011        let exit = run(args, &cfg, &mut out).unwrap();
1012        assert_eq!(exit, 0);
1013        let s = std::str::from_utf8(&stdout).unwrap();
1014        assert!(s.contains("audit_log"), "got: {s}");
1015    }
1016}