1use 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 #[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(VerifyArgs),
45 Tail(TailArgs),
47 Path,
49 Show(ShowArgs),
54}
55
56#[derive(Args)]
57pub struct ShowArgs {
58 #[arg(long)]
62 pub capability_expansions: bool,
63 #[arg(long, value_name = "AGENT_ID")]
65 pub agent_id: Option<String>,
66 #[arg(long, default_value_t = 50)]
68 pub limit: usize,
69 #[arg(long)]
71 pub json: bool,
72}
73
74#[derive(Args)]
75pub struct VerifyArgs {
76 #[arg(long)]
78 pub path: Option<String>,
79 #[arg(long, default_value_t = false)]
81 pub json: bool,
82}
83
84#[derive(Args)]
85pub struct TailArgs {
86 #[arg(long)]
88 pub path: Option<String>,
89 #[arg(long, default_value_t = 50)]
91 pub lines: usize,
92 #[arg(long)]
94 pub actor: Option<String>,
95 #[arg(long)]
97 pub namespace: Option<String>,
98 #[arg(long)]
100 pub action: Option<String>,
101 #[arg(long, default_value = "json")]
103 pub format: String,
104}
105
106pub 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
119fn 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
167fn 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 let path = dir.join("audit.log");
348 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 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 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 #[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 #[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 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 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 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 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 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 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: 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 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 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 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 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}