1use std::fs::{File, OpenOptions};
36use std::io::{BufRead, BufReader, Write};
37use std::path::{Path, PathBuf};
38use std::sync::mpsc::{Receiver, Sender};
39use std::sync::{Mutex, OnceLock};
40
41use anyhow::{Context, Result, anyhow};
42use base64::Engine;
43use base64::engine::general_purpose::STANDARD as B64;
44use chrono::{DateTime, Datelike, Utc};
45use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
46use serde::{Deserialize, Serialize};
47use sha2::{Digest, Sha256};
48
49const AUDIT_TRACE_TARGET: &str = "ai_memory::governance::audit";
51
52pub const CHAIN_HEAD_PREV_HASH: &str =
54 "0000000000000000000000000000000000000000000000000000000000000000";
55
56pub const FORENSIC_FILE_PREFIX: &str = "forensic-";
58
59pub const FORENSIC_FILE_SUFFIX: &str = ".jsonl";
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ForensicDecision {
65 pub ts: String,
66 pub actor: String,
67 pub decision: String,
68 pub kind: String,
69 pub rule_id: String,
70 pub payload: serde_json::Value,
71 pub prev_hash: String,
72 pub sig: String,
73}
74
75impl ForensicDecision {
76 #[must_use]
78 pub fn canonical_bytes(&self) -> Vec<u8> {
79 let mut clone = self.clone();
80 clone.sig.clear();
81 serde_json::to_vec(&clone).expect("ForensicDecision always serialises")
82 }
83
84 #[must_use]
86 pub fn self_hash(&self) -> String {
87 let mut h = Sha256::new();
88 h.update(self.canonical_bytes());
89 hex_encode(&h.finalize())
90 }
91}
92
93fn hex_encode(bytes: &[u8]) -> String {
94 static HEX: &[u8; 16] = b"0123456789abcdef";
95 let mut out = String::with_capacity(bytes.len() * 2);
96 for b in bytes {
97 out.push(HEX[(b >> 4) as usize] as char);
98 out.push(HEX[(b & 0x0f) as usize] as char);
99 }
100 out
101}
102
103static SINK: OnceLock<Mutex<Option<ForensicSink>>> = OnceLock::new();
108
109fn sink() -> &'static Mutex<Option<ForensicSink>> {
110 SINK.get_or_init(|| Mutex::new(None))
111}
112
113const WRITER_THREAD_NAME: &str = "ai-memory-audit-writer";
134
135enum WriteOp {
139 Append {
140 path: PathBuf,
141 line: String,
142 },
143 Barrier(Sender<()>),
144 Reset,
148}
149
150static WRITER: OnceLock<Sender<WriteOp>> = OnceLock::new();
151
152fn writer() -> &'static Sender<WriteOp> {
156 WRITER.get_or_init(|| {
157 let (tx, rx) = std::sync::mpsc::channel::<WriteOp>();
158 std::thread::Builder::new()
159 .name(WRITER_THREAD_NAME.to_string())
160 .spawn(move || run_writer(rx))
161 .expect("spawning the forensic audit writer thread");
162 tx
163 })
164}
165
166fn run_writer(rx: Receiver<WriteOp>) {
170 let mut open_file: Option<(PathBuf, File)> = None;
171 let mut pending_barriers: Vec<Sender<()>> = Vec::new();
172
173 while let Ok(first) = rx.recv() {
174 let mut batch = vec![first];
175 while let Ok(next) = rx.try_recv() {
176 batch.push(next);
177 }
178
179 let mut needs_flush = false;
180 for op in batch {
181 match op {
182 WriteOp::Append { path, line } => {
183 let reopen = open_file.as_ref().map_or(true, |(p, _)| p != &path);
184 if reopen {
185 match OpenOptions::new().create(true).append(true).open(&path) {
186 Ok(file) => open_file = Some((path, file)),
187 Err(e) => {
188 tracing::error!(
189 target: AUDIT_TRACE_TARGET,
190 "forensic: opening {} failed: {e}",
191 path.display()
192 );
193 open_file = None;
194 continue;
195 }
196 }
197 }
198 if let Some((path, file)) = open_file.as_mut() {
199 if let Err(e) = writeln!(file, "{line}") {
200 tracing::error!(
201 target: AUDIT_TRACE_TARGET,
202 "forensic: appending to {} failed: {e}",
203 path.display()
204 );
205 } else {
206 needs_flush = true;
207 }
208 }
209 }
210 WriteOp::Barrier(ack) => pending_barriers.push(ack),
211 WriteOp::Reset => {
212 if let Some((_, file)) = open_file.as_mut() {
213 let _ = file.flush();
214 }
215 open_file = None;
216 needs_flush = false;
217 }
218 }
219 }
220
221 if needs_flush {
222 if let Some((_, file)) = open_file.as_mut() {
223 let _ = file.flush();
224 }
225 }
226 for ack in pending_barriers.drain(..) {
227 let _ = ack.send(());
228 }
229 }
230}
231
232pub fn flush_blocking() {
236 let (ack, done) = std::sync::mpsc::channel();
237 if writer().send(WriteOp::Barrier(ack)).is_ok() {
238 let _ = done.recv();
239 }
240}
241
242#[cfg(test)]
246pub(crate) fn enqueue_append_for_test(path: PathBuf, line: String) {
247 let _ = writer().send(WriteOp::Append { path, line });
248}
249
250static DAEMON_AUDIT_KEY: OnceLock<SigningKey> = OnceLock::new();
281
282#[must_use]
295pub fn try_sign_audit_payload(payload_hash: &[u8]) -> Option<(Vec<u8>, &'static str)> {
296 let key = DAEMON_AUDIT_KEY.get()?;
297 let sig: Signature = key.sign(payload_hash);
298 Some((
299 sig.to_bytes().to_vec(),
300 crate::models::AttestLevel::DaemonSigned.as_str(),
301 ))
302}
303
304#[must_use]
308pub fn audit_key_is_installed() -> bool {
309 DAEMON_AUDIT_KEY.get().is_some()
310}
311
312struct ForensicSink {
313 dir: PathBuf,
314 last_hash: String,
315 signing_key: Option<SigningKey>,
316}
317
318pub fn init(dir: &Path, signing_key: Option<SigningKey>) -> Result<()> {
323 std::fs::create_dir_all(dir)
324 .with_context(|| format!("creating forensic audit dir {}", dir.display()))?;
325 let last_hash = read_chain_tail(dir).unwrap_or_else(|| CHAIN_HEAD_PREV_HASH.to_string());
326 if let Some(key) = signing_key.as_ref() {
340 let _ = DAEMON_AUDIT_KEY.set(key.clone());
341 }
342 let new_sink = ForensicSink {
343 dir: dir.to_path_buf(),
344 last_hash,
345 signing_key,
346 };
347 let mut guard = sink()
348 .lock()
349 .map_err(|_| anyhow!("forensic sink mutex poisoned"))?;
350 let _ = writer().send(WriteOp::Reset);
358 *guard = Some(new_sink);
359 Ok(())
360}
361
362pub fn shutdown() {
368 flush_blocking();
369 if let Ok(mut guard) = sink().lock() {
370 *guard = None;
371 }
372}
373
374#[must_use]
376pub fn is_enabled() -> bool {
377 sink().lock().map(|g| g.is_some()).unwrap_or(false)
378}
379
380pub fn try_record_decision(
387 actor: &str,
388 decision: &str,
389 kind: &str,
390 rule_id: &str,
391 payload: serde_json::Value,
392) -> Result<()> {
393 let mut guard = sink()
394 .lock()
395 .map_err(|_| anyhow!("forensic sink mutex poisoned"))?;
396 let Some(s) = guard.as_mut() else {
397 return Ok(());
398 };
399
400 let now = Utc::now();
401 let ts = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
402 let prev_hash = s.last_hash.clone();
403
404 let mut row = ForensicDecision {
405 ts,
406 actor: actor.to_string(),
407 decision: decision.to_string(),
408 kind: kind.to_string(),
409 rule_id: rule_id.to_string(),
410 payload,
411 prev_hash,
412 sig: String::new(),
413 };
414
415 if let Some(key) = &s.signing_key {
416 let canonical = row.canonical_bytes();
417 let sig: Signature = key.sign(&canonical);
418 row.sig = B64.encode(sig.to_bytes());
419 }
420
421 let self_hash = row.self_hash();
422 let line = serde_json::to_string(&row).context("serialising forensic row")?;
423 let file_path = daily_path(&s.dir, &now);
424
425 s.last_hash = self_hash;
432 writer()
433 .send(WriteOp::Append {
434 path: file_path,
435 line,
436 })
437 .map_err(|_| anyhow!("forensic audit writer thread has stopped"))?;
438 Ok(())
439}
440
441pub fn record_decision(
443 actor: &str,
444 decision: &str,
445 kind: &str,
446 rule_id: &str,
447 payload: serde_json::Value,
448) {
449 if let Err(e) = try_record_decision(actor, decision, kind, rule_id, payload) {
450 tracing::error!(
451 target: AUDIT_TRACE_TARGET,
452 "forensic: emission failed: {e}"
453 );
454 }
455}
456
457fn daily_path(dir: &Path, when: &DateTime<Utc>) -> PathBuf {
458 let date = when.format("%Y-%m-%d").to_string();
459 dir.join(format!(
460 "{FORENSIC_FILE_PREFIX}{date}{FORENSIC_FILE_SUFFIX}"
461 ))
462}
463
464fn read_chain_tail(dir: &Path) -> Option<String> {
465 let files = list_forensic_files(dir).ok()?;
466 let last_file = files.last()?;
467 let f = File::open(last_file).ok()?;
468 let mut last_hash: Option<String> = None;
469 for line in BufReader::new(f).lines() {
470 let Ok(line) = line else { continue };
471 if line.trim().is_empty() {
472 continue;
473 }
474 if let Ok(row) = serde_json::from_str::<ForensicDecision>(&line) {
475 last_hash = Some(row.self_hash());
476 }
477 }
478 last_hash
479}
480
481fn list_forensic_files(dir: &Path) -> Result<Vec<PathBuf>> {
482 if !dir.exists() {
483 return Ok(Vec::new());
484 }
485 let mut out: Vec<PathBuf> = Vec::new();
486 for entry in
487 std::fs::read_dir(dir).with_context(|| format!("reading forensic dir {}", dir.display()))?
488 {
489 let entry = entry?;
490 let name = entry.file_name();
491 let Some(name_str) = name.to_str() else {
492 continue;
493 };
494 if name_str.starts_with(FORENSIC_FILE_PREFIX) && name_str.ends_with(FORENSIC_FILE_SUFFIX) {
495 out.push(entry.path());
496 }
497 }
498 out.sort();
499 Ok(out)
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Default)]
507pub struct VerifyReport {
508 pub total_lines: u64,
509 pub unsigned_lines: u64,
510 pub first_failure: Option<VerifyFailure>,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
514pub struct VerifyFailure {
515 pub line_number: u64,
516 pub file: PathBuf,
517 pub kind: VerifyFailureKind,
518 pub detail: String,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
546pub enum VerifyFailureKind {
547 Parse,
548 ChainBreak,
549 Signature,
550}
551
552pub fn verify_since(
559 dir: &Path,
560 since: &str,
561 public_key: Option<&VerifyingKey>,
562) -> Result<VerifyReport> {
563 let cutoff = parse_iso_date(since)?;
564 let files = list_forensic_files(dir)?;
565 let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
566 let mut total: u64 = 0;
567 let mut unsigned: u64 = 0;
568
569 for file in &files {
570 let date = file_date(file)?;
571 if date >= cutoff {
572 break;
573 }
574 let f = File::open(file).with_context(|| crate::errors::msg::opening(file.display()))?;
575 for line in BufReader::new(f).lines() {
576 let Ok(line) = line else { continue };
577 if line.trim().is_empty() {
578 continue;
579 }
580 if let Ok(row) = serde_json::from_str::<ForensicDecision>(&line) {
581 prev_hash = row.self_hash();
582 }
583 }
584 }
585
586 for file in &files {
587 let date = file_date(file)?;
588 if date < cutoff {
589 continue;
590 }
591 let f = File::open(file).with_context(|| crate::errors::msg::opening(file.display()))?;
592 for (idx, line) in BufReader::new(f).lines().enumerate() {
593 let line_no = (idx as u64) + 1;
594 let line = line.with_context(|| format!("reading {}:{line_no}", file.display()))?;
595 if line.trim().is_empty() {
596 continue;
597 }
598 let row: ForensicDecision = match serde_json::from_str(&line) {
599 Ok(r) => r,
600 Err(e) => {
601 return Ok(VerifyReport {
602 total_lines: total,
603 unsigned_lines: unsigned,
604 first_failure: Some(VerifyFailure {
605 line_number: line_no,
606 file: file.clone(),
607 kind: VerifyFailureKind::Parse,
608 detail: format!("malformed JSON: {e}"),
609 }),
610 });
611 }
612 };
613
614 total += 1;
615
616 if row.prev_hash != prev_hash {
617 return Ok(VerifyReport {
618 total_lines: total,
619 unsigned_lines: unsigned,
620 first_failure: Some(VerifyFailure {
621 line_number: line_no,
622 file: file.clone(),
623 kind: VerifyFailureKind::ChainBreak,
624 detail: format!(
625 "prev_hash mismatch: expected {prev_hash}, got {}",
626 row.prev_hash
627 ),
628 }),
629 });
630 }
631
632 if row.sig.is_empty() {
633 unsigned += 1;
634 } else if let Some(pk) = public_key {
635 let canonical = row.canonical_bytes();
636 let sig_bytes = match B64.decode(row.sig.as_bytes()) {
637 Ok(b) => b,
638 Err(e) => {
639 return Ok(VerifyReport {
640 total_lines: total,
641 unsigned_lines: unsigned,
642 first_failure: Some(VerifyFailure {
643 line_number: line_no,
644 file: file.clone(),
645 kind: VerifyFailureKind::Signature,
646 detail: format!("base64 decode failed: {e}"),
647 }),
648 });
649 }
650 };
651 if sig_bytes.len() != 64 {
652 return Ok(VerifyReport {
653 total_lines: total,
654 unsigned_lines: unsigned,
655 first_failure: Some(VerifyFailure {
656 line_number: line_no,
657 file: file.clone(),
658 kind: VerifyFailureKind::Signature,
659 detail: format!("signature has {} bytes, expected 64", sig_bytes.len()),
660 }),
661 });
662 }
663 let mut sig_arr = [0u8; 64];
664 sig_arr.copy_from_slice(&sig_bytes);
665 let sig = Signature::from_bytes(&sig_arr);
666 if let Err(e) = pk.verify(&canonical, &sig) {
667 return Ok(VerifyReport {
668 total_lines: total,
669 unsigned_lines: unsigned,
670 first_failure: Some(VerifyFailure {
671 line_number: line_no,
672 file: file.clone(),
673 kind: VerifyFailureKind::Signature,
674 detail: crate::errors::msg::signature_verify_failed(e),
675 }),
676 });
677 }
678 }
679
680 prev_hash = row.self_hash();
681 }
682 }
683
684 Ok(VerifyReport {
685 total_lines: total,
686 unsigned_lines: unsigned,
687 first_failure: None,
688 })
689}
690
691fn parse_iso_date(s: &str) -> Result<i64> {
692 let dt = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
693 .with_context(|| format!("parsing --since {s} as YYYY-MM-DD"))?;
694 Ok(i64::from(dt.year_ce().1 as i32) * 10000
695 + i64::from(dt.month() as i32) * 100
696 + i64::from(dt.day() as i32))
697}
698
699fn file_date(path: &Path) -> Result<i64> {
700 let name = path
701 .file_name()
702 .and_then(|n| n.to_str())
703 .ok_or_else(|| anyhow!("forensic file has non-UTF8 name: {}", path.display()))?;
704 let stem = name
705 .strip_prefix(FORENSIC_FILE_PREFIX)
706 .and_then(|s| s.strip_suffix(FORENSIC_FILE_SUFFIX))
707 .ok_or_else(|| {
708 anyhow!("forensic file name not in forensic-YYYY-MM-DD.jsonl shape: {name}")
709 })?;
710 parse_iso_date(stem)
711}
712
713fn signing_key_load_is_absent(err: &anyhow::Error) -> bool {
724 err.chain().any(|cause| {
725 cause
726 .downcast_ref::<std::io::Error>()
727 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
728 })
729}
730
731pub fn load_daemon_signing_key(agent_id: &str) -> Result<Option<SigningKey>> {
742 let dir = crate::identity::keypair::default_key_dir()?;
743 if !dir.exists() {
744 return Ok(None);
745 }
746 let kp = match crate::identity::keypair::load(agent_id, &dir) {
747 Ok(k) => k,
748 Err(e) => {
749 if signing_key_load_is_absent(&e) {
750 tracing::debug!(
751 agent_id,
752 "no daemon signing key enrolled; operating unsigned \
753 (expected when no key is provisioned)"
754 );
755 } else {
756 tracing::warn!(
757 agent_id,
758 error = %e,
759 "daemon signing key is present but could not be loaded; \
760 federation/audit signing falls back to UNSIGNED — peers \
761 requiring signatures will reject posts. Fix the key file."
762 );
763 }
764 return Ok(None);
765 }
766 };
767 Ok(kp.private)
768}
769
770pub fn load_daemon_verifying_key(agent_id: &str) -> Result<Option<VerifyingKey>> {
776 let dir = crate::identity::keypair::default_key_dir()?;
777 if !dir.exists() {
778 return Ok(None);
779 }
780 match crate::identity::keypair::load(agent_id, &dir) {
781 Ok(kp) => Ok(Some(kp.public)),
782 Err(_) => Ok(None),
783 }
784}
785
786#[must_use]
799pub fn resolve_daemon_verifying_key() -> Option<VerifyingKey> {
800 DAEMON_AUDIT_KEY.get().map(SigningKey::verifying_key)
801}
802
803#[cfg(test)]
844pub(crate) fn forensic_sink_test_lock() -> &'static std::sync::Mutex<()> {
845 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
846 LOCK.get_or_init(|| std::sync::Mutex::new(()))
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use ed25519_dalek::SigningKey;
853 use rand_core::OsRng;
854 use tempfile::TempDir;
855
856 fn test_lock() -> &'static std::sync::Mutex<()> {
857 forensic_sink_test_lock()
858 }
859
860 fn fresh_key() -> SigningKey {
861 SigningKey::generate(&mut OsRng)
862 }
863
864 fn fresh_init(dir: &Path, key: Option<SigningKey>) {
865 shutdown();
866 if let Ok(entries) = std::fs::read_dir(dir) {
873 for entry in entries.flatten() {
874 let _ = std::fs::remove_file(entry.path());
875 }
876 }
877 init(dir, key).expect("forensic init");
878 }
879
880 #[test]
881 fn record_then_verify_signed_chain() {
882 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
883 let tmp = TempDir::new().unwrap();
884 let key = fresh_key();
885 let pubkey = key.verifying_key();
886 fresh_init(tmp.path(), Some(key));
887 for i in 0..3 {
888 record_decision(
889 "ai:test",
890 "allow",
891 "bash",
892 &format!("R00{i}"),
893 serde_json::json!({"command": format!("ls -la /{i}")}),
894 );
895 }
896 shutdown();
897 let since = Utc::now().format("%Y-%m-%d").to_string();
898 let report = verify_since(tmp.path(), &since, Some(&pubkey)).expect("verify");
899 assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
900 assert!(
908 report.total_lines >= 3,
909 "expected at least 3 own rows; got {} — record path is broken",
910 report.total_lines
911 );
912 }
913
914 #[test]
915 fn tampering_detected_by_verify() {
916 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
917 let tmp = TempDir::new().unwrap();
918 let key = fresh_key();
919 let pubkey = key.verifying_key();
920 fresh_init(tmp.path(), Some(key));
921 record_decision(
922 "ai:t",
923 "refuse",
924 "bash",
925 "R001",
926 serde_json::json!({"r":"no"}),
927 );
928 record_decision("ai:t", "allow", "bash", "R002", serde_json::json!({}));
929 shutdown();
930 let date = Utc::now().format("%Y-%m-%d").to_string();
931 let path = tmp.path().join(format!("forensic-{date}.jsonl"));
932 let body = std::fs::read_to_string(&path).unwrap();
933 let tampered = body.replacen("\"ai:t\"", "\"evil\"", 1);
934 std::fs::write(&path, tampered).unwrap();
935 let report = verify_since(tmp.path(), &date, Some(&pubkey)).expect("verify");
936 let failure = report.first_failure.expect("tamper must be flagged");
937 assert!(matches!(
938 failure.kind,
939 VerifyFailureKind::Signature | VerifyFailureKind::ChainBreak
940 ));
941 }
942
943 #[test]
944 fn unsigned_rows_counted_not_failed() {
945 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
946 let tmp = TempDir::new().unwrap();
947 fresh_init(tmp.path(), None);
948 record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
949 record_decision("ai:t", "allow", "bash", "R002", serde_json::json!({}));
950 shutdown();
951 let since = Utc::now().format("%Y-%m-%d").to_string();
952 let report = verify_since(tmp.path(), &since, None).expect("verify");
953 assert!(report.first_failure.is_none());
954 assert!(report.total_lines >= 2);
964 assert_eq!(report.unsigned_lines, report.total_lines);
965 }
966
967 #[test]
968 fn parse_iso_date_basic() {
969 assert!(parse_iso_date("2026-05-18").is_ok());
970 assert!(parse_iso_date("not-a-date").is_err());
971 }
972
973 #[test]
974 fn record_when_disabled_is_noop() {
975 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
976 shutdown();
977 record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
978 assert!(!is_enabled());
979 }
980
981 fn write_forensic_file(dir: &Path, date: &str, body: &str) -> PathBuf {
1018 let path = dir.join(format!(
1019 "{FORENSIC_FILE_PREFIX}{date}{FORENSIC_FILE_SUFFIX}"
1020 ));
1021 std::fs::write(&path, body).unwrap();
1022 path
1023 }
1024
1025 #[test]
1026 fn verify_since_parse_failure_first() {
1027 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1028 let tmp = TempDir::new().unwrap();
1029 let today = Utc::now().format("%Y-%m-%d").to_string();
1030 write_forensic_file(tmp.path(), &today, "{not-json\n");
1032 let report = verify_since(tmp.path(), &today, None).expect("verify ran");
1033 let f = report.first_failure.expect("parse failure surfaces");
1034 assert!(
1035 matches!(f.kind, VerifyFailureKind::Parse),
1036 "expected Parse, got {:?}",
1037 f.kind
1038 );
1039 assert_eq!(f.line_number, 1);
1040 assert!(f.detail.contains("malformed JSON"));
1041 }
1042
1043 #[test]
1044 fn verify_since_chain_break_when_prev_hash_mismatched() {
1045 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1046 let tmp = TempDir::new().unwrap();
1047 let today = Utc::now().format("%Y-%m-%d").to_string();
1048 let row = serde_json::json!({
1051 "ts": Utc::now().to_rfc3339(),
1052 "actor": "ai:t",
1053 "decision": "allow",
1054 "kind": "bash",
1055 "rule_id": "R001",
1056 "payload": {},
1057 "prev_hash": "deadbeef-not-the-real-head",
1058 "sig": ""
1059 });
1060 let body = format!("{}\n", serde_json::to_string(&row).unwrap());
1061 write_forensic_file(tmp.path(), &today, &body);
1062 let report = verify_since(tmp.path(), &today, None).expect("verify ran");
1063 let f = report.first_failure.expect("chain break surfaces");
1064 assert!(
1065 matches!(f.kind, VerifyFailureKind::ChainBreak),
1066 "expected ChainBreak, got {:?}",
1067 f.kind
1068 );
1069 assert!(f.detail.contains("prev_hash mismatch"));
1070 }
1071
1072 #[test]
1073 fn verify_since_signature_base64_decode_failure() {
1074 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1075 let tmp = TempDir::new().unwrap();
1076 let today = Utc::now().format("%Y-%m-%d").to_string();
1077 let key = fresh_key();
1078 let pubkey = key.verifying_key();
1079 let row = serde_json::json!({
1081 "ts": Utc::now().to_rfc3339(),
1082 "actor": "ai:t",
1083 "decision": "allow",
1084 "kind": "bash",
1085 "rule_id": "R001",
1086 "payload": {},
1087 "prev_hash": CHAIN_HEAD_PREV_HASH,
1088 "sig": "@@@NOT_BASE64@@@"
1089 });
1090 let body = format!("{}\n", serde_json::to_string(&row).unwrap());
1091 write_forensic_file(tmp.path(), &today, &body);
1092 let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify ran");
1093 let f = report.first_failure.expect("signature failure surfaces");
1094 assert!(
1095 matches!(f.kind, VerifyFailureKind::Signature),
1096 "expected Signature, got {:?}",
1097 f.kind
1098 );
1099 assert!(f.detail.contains("base64 decode failed"));
1100 }
1101
1102 #[test]
1103 fn verify_since_signature_wrong_byte_length() {
1104 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1105 let tmp = TempDir::new().unwrap();
1106 let today = Utc::now().format("%Y-%m-%d").to_string();
1107 let key = fresh_key();
1108 let pubkey = key.verifying_key();
1109 let sig_short = B64.encode([1u8, 2, 3, 4]);
1111 let row = serde_json::json!({
1112 "ts": Utc::now().to_rfc3339(),
1113 "actor": "ai:t",
1114 "decision": "allow",
1115 "kind": "bash",
1116 "rule_id": "R001",
1117 "payload": {},
1118 "prev_hash": CHAIN_HEAD_PREV_HASH,
1119 "sig": sig_short
1120 });
1121 let body = format!("{}\n", serde_json::to_string(&row).unwrap());
1122 write_forensic_file(tmp.path(), &today, &body);
1123 let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify ran");
1124 let f = report.first_failure.expect("signature failure surfaces");
1125 assert!(matches!(f.kind, VerifyFailureKind::Signature));
1126 assert!(
1127 f.detail.contains("signature has") && f.detail.contains("expected 64"),
1128 "got: {}",
1129 f.detail
1130 );
1131 }
1132
1133 #[test]
1134 fn verify_since_signature_verify_failure_for_wrong_key() {
1135 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1136 let tmp = TempDir::new().unwrap();
1137 let key_a = fresh_key();
1140 let key_b = fresh_key();
1141 let pub_b = key_b.verifying_key();
1142 fresh_init(tmp.path(), Some(key_a));
1143 record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
1144 shutdown();
1145 let today = Utc::now().format("%Y-%m-%d").to_string();
1146 let report = verify_since(tmp.path(), &today, Some(&pub_b)).expect("verify ran");
1147 let f = report.first_failure.expect("verify failure surfaces");
1148 assert!(matches!(f.kind, VerifyFailureKind::Signature));
1149 assert!(
1150 f.detail.contains("signature verify failed"),
1151 "got: {}",
1152 f.detail
1153 );
1154 }
1155
1156 #[test]
1157 fn verify_since_walks_pre_cutoff_files_to_seed_chain_head() {
1158 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1159 let tmp = TempDir::new().unwrap();
1160 let key = fresh_key();
1165 let pubkey = key.verifying_key();
1166
1167 let old_row_unsigned_canonical = ForensicDecision {
1169 ts: "2026-01-01T00:00:00.000Z".to_string(),
1170 actor: "ai:old".into(),
1171 decision: "allow".into(),
1172 kind: "bash".into(),
1173 rule_id: "R001".into(),
1174 payload: serde_json::json!({}),
1175 prev_hash: CHAIN_HEAD_PREV_HASH.to_string(),
1176 sig: String::new(),
1177 };
1178 let canonical = old_row_unsigned_canonical.canonical_bytes();
1179 let sig: Signature = key.sign(&canonical);
1180 let mut old_row = old_row_unsigned_canonical;
1181 old_row.sig = B64.encode(sig.to_bytes());
1182 let old_hash = old_row.self_hash();
1183 let old_body = format!("{}\n", serde_json::to_string(&old_row).unwrap());
1184 write_forensic_file(tmp.path(), "2026-01-01", &old_body);
1185
1186 fresh_init(tmp.path(), Some(key));
1189 record_decision("ai:new", "allow", "bash", "R001", serde_json::json!({}));
1190 shutdown();
1191
1192 let today = Utc::now().format("%Y-%m-%d").to_string();
1193 let report = verify_since(tmp.path(), &today, Some(&pubkey)).expect("verify");
1194 assert!(report.first_failure.is_none(), "{:?}", report);
1195 assert!(report.total_lines >= 1);
1202 let _ = old_hash;
1205 }
1206
1207 #[test]
1208 fn verify_since_blank_lines_ignored() {
1209 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1210 let tmp = TempDir::new().unwrap();
1211 let today = Utc::now().format("%Y-%m-%d").to_string();
1212 write_forensic_file(tmp.path(), &today, "\n\n\n");
1214 let report = verify_since(tmp.path(), &today, None).expect("verify ran");
1215 assert!(report.first_failure.is_none());
1216 assert_eq!(report.total_lines, 0);
1217 }
1218
1219 #[test]
1220 fn verify_since_rejects_unparseable_date() {
1221 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1222 let tmp = TempDir::new().unwrap();
1223 let err = verify_since(tmp.path(), "not-a-date", None).expect_err("expected parse err");
1224 assert!(err.to_string().contains("parsing --since"));
1225 }
1226
1227 #[test]
1228 fn verify_since_returns_empty_report_when_dir_does_not_exist() {
1229 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1230 let tmp = TempDir::new().unwrap();
1231 let nonexistent = tmp.path().join("never-created");
1234 let today = Utc::now().format("%Y-%m-%d").to_string();
1235 let report = verify_since(&nonexistent, &today, None).expect("verify ran");
1236 assert!(report.first_failure.is_none());
1237 assert_eq!(report.total_lines, 0);
1238 }
1239
1240 #[test]
1241 fn file_date_errors_for_unrecognised_filename_shape() {
1242 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1243 let tmp = TempDir::new().unwrap();
1244 let bad = tmp.path().join("not-forensic.txt");
1249 let err = file_date(&bad).expect_err("filename mismatch surfaces");
1250 let chain = format!("{err}");
1251 assert!(
1252 chain.contains("not in forensic-YYYY-MM-DD.jsonl shape"),
1253 "got: {chain}"
1254 );
1255 }
1256
1257 #[test]
1258 fn list_forensic_files_skips_non_matching_names() {
1259 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1260 let tmp = TempDir::new().unwrap();
1261 std::fs::write(tmp.path().join("README.md"), "x").unwrap();
1263 std::fs::write(tmp.path().join("forensic-not-a-date.jsonl"), "x").unwrap();
1264 std::fs::write(tmp.path().join("foo.jsonl"), "x").unwrap();
1265 write_forensic_file(tmp.path(), "2026-02-15", "");
1266 let files = list_forensic_files(tmp.path()).unwrap();
1267 let names: Vec<String> = files
1273 .iter()
1274 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
1275 .collect();
1276 assert!(
1277 names.iter().any(|n| n == "forensic-2026-02-15.jsonl"),
1278 "good file present: {names:?}"
1279 );
1280 assert!(!names.iter().any(|n| n == "README.md"));
1281 assert!(!names.iter().any(|n| n == "foo.jsonl"));
1282 }
1283
1284 #[test]
1285 fn parse_iso_date_edge_cases() {
1286 assert!(parse_iso_date("2024-02-29").is_ok());
1288 assert!(parse_iso_date("2026-13-01").is_err());
1290 assert!(parse_iso_date("").is_err());
1292 let code = parse_iso_date("2026-05-19").unwrap();
1294 assert_eq!(code, 20260519);
1295 }
1296
1297 #[test]
1298 fn read_chain_tail_returns_none_for_empty_dir() {
1299 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1300 let tmp = TempDir::new().unwrap();
1301 assert!(read_chain_tail(tmp.path()).is_none());
1302 }
1303
1304 #[test]
1305 fn read_chain_tail_returns_last_hash_after_record() {
1306 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1307 let tmp = TempDir::new().unwrap();
1308 fresh_init(tmp.path(), None);
1309 record_decision("ai:t", "allow", "bash", "R001", serde_json::json!({}));
1310 shutdown();
1311 let tail = read_chain_tail(tmp.path()).expect("tail present after record");
1312 assert!(!tail.is_empty());
1313 assert_ne!(tail, CHAIN_HEAD_PREV_HASH);
1314 }
1315
1316 #[test]
1317 fn is_enabled_reflects_sink_state() {
1318 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1319 shutdown();
1320 assert!(!is_enabled(), "sink starts disabled after shutdown");
1321 let tmp = TempDir::new().unwrap();
1322 fresh_init(tmp.path(), None);
1323 assert!(is_enabled(), "init flips is_enabled to true");
1324 shutdown();
1325 assert!(!is_enabled(), "shutdown flips it back");
1326 }
1327
1328 #[test]
1329 fn load_daemon_signing_key_returns_none_when_dir_missing() {
1330 let tmp = TempDir::new().unwrap();
1333 let nonexistent = tmp.path().join("never-created");
1334 let _g = crate::identity::keypair::key_dir_env_lock()
1335 .lock()
1336 .unwrap_or_else(|e| e.into_inner());
1337 let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
1338 unsafe {
1342 std::env::set_var("AI_MEMORY_KEY_DIR", &nonexistent);
1343 }
1344 let res = load_daemon_signing_key("ai:nobody");
1345 if let Some(p) = prior {
1346 unsafe {
1347 std::env::set_var("AI_MEMORY_KEY_DIR", p);
1348 }
1349 } else {
1350 unsafe {
1351 std::env::remove_var("AI_MEMORY_KEY_DIR");
1352 }
1353 }
1354 let got = res.expect("non-existent dir returns Ok(None)");
1355 assert!(got.is_none());
1356 }
1357
1358 #[test]
1359 fn load_daemon_verifying_key_returns_none_when_dir_missing() {
1360 let tmp = TempDir::new().unwrap();
1361 let nonexistent = tmp.path().join("never-created");
1362 let _g = crate::identity::keypair::key_dir_env_lock()
1363 .lock()
1364 .unwrap_or_else(|e| e.into_inner());
1365 let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
1366 unsafe {
1367 std::env::set_var("AI_MEMORY_KEY_DIR", &nonexistent);
1368 }
1369 let res = load_daemon_verifying_key("ai:nobody");
1370 if let Some(p) = prior {
1371 unsafe {
1372 std::env::set_var("AI_MEMORY_KEY_DIR", p);
1373 }
1374 } else {
1375 unsafe {
1376 std::env::remove_var("AI_MEMORY_KEY_DIR");
1377 }
1378 }
1379 let got = res.expect("non-existent dir returns Ok(None)");
1380 assert!(got.is_none());
1381 }
1382
1383 #[test]
1384 fn load_daemon_keys_return_none_when_no_keypair_for_agent() {
1385 let tmp = TempDir::new().unwrap();
1389 std::fs::create_dir_all(tmp.path()).unwrap();
1390 let _g = crate::identity::keypair::key_dir_env_lock()
1391 .lock()
1392 .unwrap_or_else(|e| e.into_inner());
1393 let prior = std::env::var("AI_MEMORY_KEY_DIR").ok();
1394 unsafe {
1395 std::env::set_var("AI_MEMORY_KEY_DIR", tmp.path());
1396 }
1397 let sk = load_daemon_signing_key("ai:no-keypair-on-disk");
1398 let vk = load_daemon_verifying_key("ai:no-keypair-on-disk");
1399 if let Some(p) = prior {
1400 unsafe {
1401 std::env::set_var("AI_MEMORY_KEY_DIR", p);
1402 }
1403 } else {
1404 unsafe {
1405 std::env::remove_var("AI_MEMORY_KEY_DIR");
1406 }
1407 }
1408 assert!(sk.expect("Ok").is_none());
1409 assert!(vk.expect("Ok").is_none());
1410 }
1411
1412 #[test]
1413 fn signing_key_load_is_absent_only_for_notfound_in_chain() {
1414 let notfound: anyhow::Error =
1417 std::io::Error::new(std::io::ErrorKind::NotFound, "no such file").into();
1418 assert!(signing_key_load_is_absent(¬found));
1419
1420 let wrapped: anyhow::Error =
1424 anyhow::Error::from(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1425 .context("reading public key");
1426 assert!(signing_key_load_is_absent(&wrapped));
1427
1428 let denied: anyhow::Error =
1432 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "mode bits").into();
1433 assert!(!signing_key_load_is_absent(&denied));
1434
1435 let corrupt = anyhow::anyhow!("key material is the wrong length");
1438 assert!(!signing_key_load_is_absent(&corrupt));
1439 }
1440
1441 #[test]
1442 fn cross_thread_bleed_is_reproducible_without_lock_then_recovered_by_fresh_init() {
1443 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1444 let tmp = TempDir::new().unwrap();
1445 let key = fresh_key();
1446 let pubkey = key.verifying_key();
1447 fresh_init(tmp.path(), Some(key));
1448
1449 let agent_phase_a = "ai:test-a";
1454 let agent_bleed = "ai:bleed-from-elsewhere";
1455 let agent_phase_b = "ai:test-b";
1456
1457 for i in 0..3 {
1459 record_decision(
1460 agent_phase_a,
1461 "allow",
1462 "bash",
1463 &format!("R00{i}"),
1464 serde_json::json!({"a": i}),
1465 );
1466 }
1467
1468 let handle = std::thread::spawn(move || {
1475 record_decision(
1476 agent_bleed,
1477 "allow",
1478 "bash",
1479 "R999",
1480 serde_json::json!({"source": "background-thread"}),
1481 );
1482 });
1483 handle.join().expect("background thread");
1484
1485 shutdown();
1486 let since = Utc::now().format("%Y-%m-%d").to_string();
1487 let report_after_bleed =
1488 verify_since(tmp.path(), &since, Some(&pubkey)).expect("verify after bleed");
1489
1490 assert!(
1501 report_after_bleed.total_lines >= 3,
1502 "expected at least 3 own rows; got {} — bleed-vector test framework broken",
1503 report_after_bleed.total_lines
1504 );
1505 fresh_init(tmp.path(), Some(fresh_key()));
1518 record_decision(
1519 agent_phase_b,
1520 "allow",
1521 "bash",
1522 "R001",
1523 serde_json::json!({"b": 1}),
1524 );
1525 shutdown();
1526
1527 let recovered_path = tmp.path().join(format!(
1535 "{FORENSIC_FILE_PREFIX}{since}{FORENSIC_FILE_SUFFIX}"
1536 ));
1537 let recovered =
1538 std::fs::read_to_string(&recovered_path).expect("read recovered forensic file");
1539 assert!(
1540 !recovered.contains(agent_phase_a),
1541 "fresh_init must clear pre-bleed phase-A rows; found {agent_phase_a} in {recovered_path:?}"
1542 );
1543 assert!(
1544 !recovered.contains(agent_bleed),
1545 "fresh_init must clear the bled row; found {agent_bleed} in {recovered_path:?}"
1546 );
1547 assert!(
1548 recovered.contains(agent_phase_b),
1549 "test-B's own row must survive fresh_init; missing {agent_phase_b} in {recovered_path:?}"
1550 );
1551 }
1552
1553 #[test]
1556 fn flush_blocking_makes_records_durable_without_shutdown() {
1557 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1558 let tmp = TempDir::new().unwrap();
1559 fresh_init(tmp.path(), None);
1560 let actor = "ai:flush-durable-test";
1568 let n = 25;
1569 for i in 0..n {
1570 record_decision(
1571 actor,
1572 "allow",
1573 "bash",
1574 "R001",
1575 serde_json::json!({ "i": i }),
1576 );
1577 }
1578 flush_blocking();
1580 let date = Utc::now().format("%Y-%m-%d").to_string();
1581 let path = tmp.path().join(format!("forensic-{date}.jsonl"));
1582 let body = std::fs::read_to_string(&path).expect("file written by background writer");
1583 let ours = body
1584 .lines()
1585 .filter_map(|l| serde_json::from_str::<ForensicDecision>(l).ok())
1586 .filter(|row| row.actor == actor)
1587 .count();
1588 assert_eq!(ours, n, "every enqueued row drained to disk");
1589 shutdown();
1590 }
1591
1592 #[test]
1593 fn writer_reopens_when_destination_path_changes() {
1594 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1595 let tmp_a = TempDir::new().unwrap();
1597 fresh_init(tmp_a.path(), None);
1598 record_decision("ai:a", "allow", "bash", "R001", serde_json::json!({}));
1599 shutdown();
1600 let tmp_b = TempDir::new().unwrap();
1603 fresh_init(tmp_b.path(), None);
1604 record_decision("ai:b", "allow", "bash", "R002", serde_json::json!({}));
1605 shutdown();
1606 let date = Utc::now().format("%Y-%m-%d").to_string();
1607 let body_a =
1608 std::fs::read_to_string(tmp_a.path().join(format!("forensic-{date}.jsonl"))).unwrap();
1609 let body_b =
1610 std::fs::read_to_string(tmp_b.path().join(format!("forensic-{date}.jsonl"))).unwrap();
1611 assert!(body_a.contains("ai:a") && !body_a.contains("ai:b"));
1612 assert!(body_b.contains("ai:b") && !body_b.contains("ai:a"));
1613 }
1614
1615 #[test]
1616 fn writer_logs_and_recovers_when_open_fails() {
1617 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1618 let tmp = TempDir::new().unwrap();
1619 let bad = tmp.path().join("missing-parent").join("forensic.jsonl");
1622 enqueue_append_for_test(bad.clone(), "{}".to_string());
1623 flush_blocking();
1624 assert!(!bad.exists(), "open failure must not create the file");
1625 let good = tmp.path().join("good.jsonl");
1627 enqueue_append_for_test(good.clone(), "{\"ok\":true}".to_string());
1628 flush_blocking();
1629 let body = std::fs::read_to_string(&good).expect("good append after prior error");
1630 assert!(body.contains("\"ok\":true"));
1631 }
1632
1633 #[test]
1634 fn reinit_invalidates_cached_handle_over_same_path_new_inode() {
1635 let _g = test_lock().lock().unwrap_or_else(|e| e.into_inner());
1636 let tmp = TempDir::new().unwrap();
1642 let date = Utc::now().format("%Y-%m-%d").to_string();
1643 let path = tmp.path().join(format!("forensic-{date}.jsonl"));
1644
1645 fresh_init(tmp.path(), None);
1646 record_decision("ai:epoch-1", "allow", "bash", "R001", serde_json::json!({}));
1647 flush_blocking();
1648 assert!(path.exists(), "epoch-1 row created the file");
1649
1650 fresh_init(tmp.path(), None);
1653 record_decision("ai:epoch-2", "allow", "bash", "R002", serde_json::json!({}));
1654 flush_blocking();
1655
1656 let body = std::fs::read_to_string(&path).expect("epoch-2 row on the recreated file");
1657 let lines: Vec<&str> = body.lines().filter(|l| !l.trim().is_empty()).collect();
1658 assert!(
1666 !lines.is_empty(),
1667 "epoch-2's row is visible on the new inode"
1668 );
1669 assert!(body.contains("ai:epoch-2") && !body.contains("ai:epoch-1"));
1670 shutdown();
1671 }
1672}