//! Tests for audit and session log records against JSONL fixtures. Do not edit these or the fixtures. use proto::{ ApprovalAnswer, AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, DenyReason, Epoch, Hash32, LogRecord, ResultStatus, SessionId, Timestamp, ToolCall, }; use serde::{Serialize, de::DeserializeOwned}; use std::fmt::Debug; const SEQ_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; const REV_HEX: &str = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; fn ts(s: &str) -> Timestamp { Timestamp::parse(s).unwrap() } /// Line `i` of the fixture must decode to `want[i]`, and `want[i]` must encode to exactly that line. fn check(name: &str, want: &[T]) { let path = format!( "{}/tests/fixtures/records/{name}", env!("CARGO_MANIFEST_DIR") ); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); let lines: Vec<&str> = text.lines().collect(); assert_eq!(lines.len(), want.len(), "{name}: number of lines"); for (i, (line, want)) in lines.iter().zip(want).enumerate() { let got: T = serde_json::from_str(line).unwrap_or_else(|e| panic!("{name}:{}: {e}", i + 1)); assert_eq!(&got, want, "{name}:{}: decoded value", i + 1); assert_eq!( &serde_json::to_string(want).unwrap(), line, "{name}:{}: encoded bytes", i + 1 ); } } fn sid(s: &str) -> SessionId { SessionId::new(s).unwrap() } fn record(seq: u64, time: &str, prev: Hash32, event: AuditEvent) -> AuditRecord { AuditRecord { seq, time: ts(time), prev, event, } } /// One line per event variant, every `DecisionRecord` variant, and every option both set and unset. #[test] fn audit_records() { let seq_hash = Hash32::from_hex(SEQ_HEX).unwrap(); let rev_hash = Hash32::from_hex(REV_HEX).unwrap(); let want = [ record( 0, "2026-09-17T08:05:00.000Z", Hash32::ZERO, AuditEvent::Decision { session: sid("mm-thread-42"), call: CallId(1), tool: "read_file".to_string(), arguments: r#"{"path":"/etc/hosts"}"#.to_string(), outcome: DecisionRecord::Allowed {}, grant: Some("read-etc".to_string()), grant_sha256: Some(seq_hash), taint: DataClass::Private, untrusted: false, }, ), record( 1, "2026-09-17T08:05:00.500Z", seq_hash, AuditEvent::Result { session: sid("mm-thread-42"), call: CallId(1), decision: 0, status: ResultStatus::Result, class: DataClass::Secret, untrusted: true, truncated: true, bytes: 65536, sha256: rev_hash, taint_after: DataClass::Secret, }, ), record( 2, "2026-09-17T08:05:01.250Z", rev_hash, AuditEvent::Decision { session: sid("mm-thread-42"), call: CallId(2), tool: "shell".to_string(), arguments: r#"{"command":"df -h"}"#.to_string(), outcome: DecisionRecord::Ask {}, grant: Some("shell-ask".to_string()), grant_sha256: Some(rev_hash), taint: DataClass::Secret, untrusted: true, }, ), record( 3, "2026-09-17T08:06:00.000Z", seq_hash, AuditEvent::Approval { session: sid("mm-thread-42"), call: CallId(2), decision: 2, answer: ApprovalAnswer::Approved, by: Some("u8f3k2".to_string()), post: Some("p9x7".to_string()), reason: None, outcome: DecisionRecord::Allowed {}, grant: Some("shell-auto".to_string()), grant_sha256: Some(seq_hash), taint: DataClass::Secret, untrusted: true, }, ), record( 4, "2026-09-17T08:06:00.100Z", rev_hash, AuditEvent::Result { session: sid("mm-thread-42"), call: CallId(2), decision: 2, status: ResultStatus::Failed, class: DataClass::Private, untrusted: false, truncated: false, bytes: 27, sha256: seq_hash, taint_after: DataClass::Secret, }, ), record( 5, "2026-09-17T08:07:00.000Z", seq_hash, AuditEvent::Decision { session: sid("cron-morning"), call: CallId(1), tool: "rm_rf".to_string(), arguments: "{}".to_string(), outcome: DecisionRecord::Denied { reason: DenyReason::NoGrant, }, grant: None, grant_sha256: None, taint: DataClass::Private, untrusted: false, }, ), record( 6, "2026-09-17T08:08:00.000Z", rev_hash, AuditEvent::Approval { session: sid("cron-morning"), call: CallId(3), decision: 4, answer: ApprovalAnswer::Refused, by: Some("bxctl".to_string()), post: None, reason: Some("not \"now\"".to_string()), outcome: DecisionRecord::Denied { reason: DenyReason::ApprovalRefused, }, grant: None, grant_sha256: None, taint: DataClass::Private, untrusted: false, }, ), record( 7, "2026-09-17T08:23:00.000Z", seq_hash, AuditEvent::Approval { session: sid("cron-morning"), call: CallId(4), decision: 5, answer: ApprovalAnswer::Expired, by: None, post: None, reason: None, outcome: DecisionRecord::Denied { reason: DenyReason::ApprovalExpired, }, grant: None, grant_sha256: None, taint: DataClass::Public, untrusted: false, }, ), record( 8, "2026-09-18T00:00:00.000Z", rev_hash, AuditEvent::Recovery { torn_bytes: 117, torn_sha256: seq_hash, }, ), record( 9, "2026-09-18T00:00:01.000Z", seq_hash, AuditEvent::AcceptedBreak { file: "2026-09-17.jsonl".to_string(), line: 7, last_good: rev_hash, }, ), ]; check("audit.jsonl", &want); } #[test] fn session_log_records() { let want = [ LogRecord::SessionStart { time: ts("2026-09-17T08:05:00.000Z"), session: SessionId::new("mm-thread-42").unwrap(), epoch: Epoch(0), slot: 0, baseline: Hash32::from_hex(SEQ_HEX).unwrap(), }, LogRecord::User { time: ts("2026-09-17T08:05:01.000Z"), content: "What is in /etc/hosts?".to_string(), }, LogRecord::Assistant { time: ts("2026-09-17T08:05:03.000Z"), content: None, reasoning_content: Some("The user wants a file.\nI will read it.".to_string()), tool_calls: vec![ToolCall { id: "call_a1".to_string(), name: "read_file".to_string(), arguments: r#"{"path":"/etc/hosts"}"#.to_string(), }], }, LogRecord::ToolResult { time: ts("2026-09-17T08:05:04.000Z"), call: CallId(1), tool_call_id: "call_a1".to_string(), content: "127.0.0.1 localhost\n".to_string(), class: DataClass::Private, untrusted: true, truncated: false, }, LogRecord::Assistant { time: ts("2026-09-17T08:05:06.000Z"), content: Some("It maps localhost to 127.0.0.1.".to_string()), reasoning_content: None, tool_calls: vec![], }, LogRecord::CacheLoss { time: ts("2026-09-17T09:00:00.000Z"), expected: 30695, got: 0, }, LogRecord::EpochEnd { time: ts("2026-09-17T12:00:00.000Z"), next: Epoch(1), summary: "Looked at /etc/hosts.".to_string(), }, ]; check("session.jsonl", &want); } #[test] fn unknown_fields_and_types_are_rejected() { let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#; assert!(serde_json::from_str::(user).is_ok()); let extra = user.replacen("\"content\"", "\"role\":\"user\",\"content\"", 1); assert!(serde_json::from_str::(&extra).is_err()); let unknown_type = user.replacen("\"user\"", "\"system\"", 1); assert!(serde_json::from_str::(&unknown_type).is_err()); // A variant with no fields must reject an unknown key too. A serde unit variant would not. for decision in [r#"{"outcome":"allowed"}"#, r#"{"outcome":"ask"}"#] { assert!(serde_json::from_str::(decision).is_ok()); let extra = decision.replacen('}', ",\"why\":\"\"}", 1); assert!( serde_json::from_str::(&extra).is_err(), "{extra}" ); } let denied = r#"{"outcome":"denied","reason":"no_grant"}"#; assert!(serde_json::from_str::(denied).is_ok()); let extra = denied.replacen("\"reason\"", "\"grant\":null,\"reason\"", 1); assert!(serde_json::from_str::(&extra).is_err()); let approved = r#"{"outcome":"approved"}"#; assert!(serde_json::from_str::(approved).is_err()); }