1use 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 #[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(VerifyArgs),
46 Tail(TailArgs),
48 Path,
50 Show(ShowArgs),
55}
56
57#[derive(Args)]
58pub struct ShowArgs {
59 #[arg(long)]
63 pub capability_expansions: bool,
64 #[arg(long, value_name = "AGENT_ID")]
66 pub agent_id: Option<String>,
67 #[arg(long, default_value_t = 50)]
69 pub limit: usize,
70 #[arg(long)]
72 pub json: bool,
73}
74
75#[derive(Args)]
76pub struct VerifyArgs {
77 #[arg(long)]
79 pub path: Option<String>,
80 #[arg(long, default_value_t = false)]
82 pub json: bool,
83 #[arg(long, value_name = "ISO_DATE")]
91 pub since: Option<String>,
92 #[arg(long, value_name = "AGENT_ID")]
98 pub forensic_agent_id: Option<String>,
99}
100
101#[derive(Args)]
102pub struct TailArgs {
103 #[arg(long)]
105 pub path: Option<String>,
106 #[arg(long, default_value_t = 50)]
108 pub lines: usize,
109 #[arg(long)]
111 pub actor: Option<String>,
112 #[arg(long)]
114 pub namespace: Option<String>,
115 #[arg(long)]
117 pub action: Option<String>,
118 #[arg(long, default_value = "json")]
120 pub format: String,
121}
122
123pub 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
136fn 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
184fn 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 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
288fn 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 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 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 let path = dir.join("audit.log");
491 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 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 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 #[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 #[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 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 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 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 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 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 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: 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 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 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 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 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 #[test]
1184 fn audit_verify_with_since_dispatches_to_forensic_verify_json() {
1185 let tmp = tempfile::tempdir().unwrap();
1186 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 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 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 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 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}