1use alloc::string::String;
10use sha2::{Digest, Sha256};
11
12use crate::{AgentId, SessionId};
13
14#[repr(u32)]
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub enum AuditEventType {
26 ToolCallIntercepted = 0,
28 PolicyViolation = 1,
30 CredentialLeakBlocked = 2,
32 ApprovalRequested = 3,
34 ApprovalGranted = 4,
36 ApprovalDenied = 5,
38 BudgetLimitApproached = 6,
40 BudgetLimitExceeded = 7,
42 ApprovalTimedOut = 8,
44 ApprovalRouted = 9,
46 ApprovalEscalated = 10,
48 AgentForceDeregistered = 11,
50 MessageBlocked = 12,
52 ToolDispatched = 13,
58 A2ACallIntercepted = 14,
65 A2AImpersonationAttempted = 15,
72 SandboxStarted = 16,
84 SandboxFilesystemBlocked = 17,
91 SandboxCpuTimeout = 18,
98 SandboxOomKilled = 19,
108 SandboxTerminated = 20,
114}
115
116impl AuditEventType {
117 pub fn as_str(&self) -> &'static str {
121 match self {
122 Self::ToolCallIntercepted => "ToolCallIntercepted",
123 Self::PolicyViolation => "PolicyViolation",
124 Self::CredentialLeakBlocked => "CredentialLeakBlocked",
125 Self::ApprovalRequested => "ApprovalRequested",
126 Self::ApprovalGranted => "ApprovalGranted",
127 Self::ApprovalDenied => "ApprovalDenied",
128 Self::BudgetLimitApproached => "BudgetLimitApproached",
129 Self::BudgetLimitExceeded => "BudgetLimitExceeded",
130 Self::ApprovalTimedOut => "ApprovalTimedOut",
131 Self::ApprovalRouted => "ApprovalRouted",
132 Self::ApprovalEscalated => "ApprovalEscalated",
133 Self::AgentForceDeregistered => "AgentForceDeregistered",
134 Self::MessageBlocked => "MessageBlocked",
135 Self::ToolDispatched => "ToolDispatched",
136 Self::A2ACallIntercepted => "A2ACallIntercepted",
137 Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
138 Self::SandboxStarted => "SandboxStarted",
139 Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
140 Self::SandboxCpuTimeout => "SandboxCpuTimeout",
141 Self::SandboxOomKilled => "SandboxOomKilled",
142 Self::SandboxTerminated => "SandboxTerminated",
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Default)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub struct Lineage {
160 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
162 pub root_agent_id: Option<AgentId>,
163 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
165 pub parent_agent_id: Option<AgentId>,
166 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
168 pub team_id: Option<String>,
169 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
176 pub org_id: Option<String>,
177 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
179 pub delegation_reason: Option<String>,
180 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
182 pub spawned_by_tool: Option<String>,
183 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
185 pub depth: Option<u32>,
186}
187
188#[cfg(feature = "std")]
217#[derive(Debug, Clone, PartialEq, Eq, Default)]
218#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
219pub struct Redaction {
220 pub credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
223 pub redacted_payload: Option<alloc::string::String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
252#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
253pub struct AuditEntry {
254 seq: u64,
255 timestamp_ns: u64,
256 event_type: AuditEventType,
257 agent_id: AgentId,
258 session_id: SessionId,
259 payload: String,
260 previous_hash: [u8; 32],
261 entry_hash: [u8; 32],
262 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
263 root_agent_id: Option<AgentId>,
264 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
265 parent_agent_id: Option<AgentId>,
266 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
267 team_id: Option<String>,
268 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
270 org_id: Option<String>,
271 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
272 delegation_reason: Option<String>,
273 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
274 spawned_by_tool: Option<String>,
275 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
276 depth: Option<u32>,
277 #[cfg(feature = "std")]
278 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
279 credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
280 #[cfg(feature = "std")]
281 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
282 redacted_payload: Option<String>,
283}
284
285impl AuditEntry {
286 pub fn new(
318 seq: u64,
319 timestamp_ns: u64,
320 event_type: AuditEventType,
321 agent_id: AgentId,
322 session_id: SessionId,
323 payload: String,
324 previous_hash: [u8; 32],
325 ) -> Self {
326 let entry_hash = Self::compute_hash(
327 seq,
328 timestamp_ns,
329 &event_type,
330 &agent_id,
331 &session_id,
332 &previous_hash,
333 &payload,
334 &Lineage::default(),
335 #[cfg(feature = "std")]
336 &Redaction::default(),
337 );
338 Self {
339 seq,
340 timestamp_ns,
341 event_type,
342 agent_id,
343 session_id,
344 payload,
345 previous_hash,
346 entry_hash,
347 root_agent_id: None,
348 parent_agent_id: None,
349 team_id: None,
350 org_id: None,
351 delegation_reason: None,
352 spawned_by_tool: None,
353 depth: None,
354 #[cfg(feature = "std")]
355 credential_findings: alloc::vec::Vec::new(),
356 #[cfg(feature = "std")]
357 redacted_payload: None,
358 }
359 }
360
361 #[allow(clippy::too_many_arguments)]
368 pub fn new_with_lineage(
369 seq: u64,
370 timestamp_ns: u64,
371 event_type: AuditEventType,
372 agent_id: AgentId,
373 session_id: SessionId,
374 payload: String,
375 previous_hash: [u8; 32],
376 lineage: Lineage,
377 ) -> Self {
378 let entry_hash = Self::compute_hash(
379 seq,
380 timestamp_ns,
381 &event_type,
382 &agent_id,
383 &session_id,
384 &previous_hash,
385 &payload,
386 &lineage,
387 #[cfg(feature = "std")]
388 &Redaction::default(),
389 );
390 Self {
391 seq,
392 timestamp_ns,
393 event_type,
394 agent_id,
395 session_id,
396 payload,
397 previous_hash,
398 entry_hash,
399 root_agent_id: lineage.root_agent_id,
400 parent_agent_id: lineage.parent_agent_id,
401 team_id: lineage.team_id,
402 org_id: lineage.org_id,
403 delegation_reason: lineage.delegation_reason,
404 spawned_by_tool: lineage.spawned_by_tool,
405 depth: lineage.depth,
406 #[cfg(feature = "std")]
407 credential_findings: alloc::vec::Vec::new(),
408 #[cfg(feature = "std")]
409 redacted_payload: None,
410 }
411 }
412
413 #[cfg(feature = "std")]
425 #[allow(clippy::too_many_arguments)]
426 pub fn new_with_lineage_and_redaction(
427 seq: u64,
428 timestamp_ns: u64,
429 event_type: AuditEventType,
430 agent_id: AgentId,
431 session_id: SessionId,
432 payload: String,
433 previous_hash: [u8; 32],
434 lineage: Lineage,
435 redaction: Redaction,
436 ) -> Self {
437 let entry_hash = Self::compute_hash(
438 seq,
439 timestamp_ns,
440 &event_type,
441 &agent_id,
442 &session_id,
443 &previous_hash,
444 &payload,
445 &lineage,
446 &redaction,
447 );
448 Self {
449 seq,
450 timestamp_ns,
451 event_type,
452 agent_id,
453 session_id,
454 payload,
455 previous_hash,
456 entry_hash,
457 root_agent_id: lineage.root_agent_id,
458 parent_agent_id: lineage.parent_agent_id,
459 team_id: lineage.team_id,
460 org_id: lineage.org_id,
461 delegation_reason: lineage.delegation_reason,
462 spawned_by_tool: lineage.spawned_by_tool,
463 depth: lineage.depth,
464 credential_findings: redaction.credential_findings,
465 redacted_payload: redaction.redacted_payload,
466 }
467 }
468
469 #[inline]
475 pub fn seq(&self) -> u64 {
476 self.seq
477 }
478
479 #[inline]
481 pub fn timestamp_ns(&self) -> u64 {
482 self.timestamp_ns
483 }
484
485 #[inline]
487 pub fn event_type(&self) -> AuditEventType {
488 self.event_type
489 }
490
491 #[inline]
493 pub fn agent_id(&self) -> AgentId {
494 self.agent_id
495 }
496
497 #[inline]
499 pub fn session_id(&self) -> SessionId {
500 self.session_id
501 }
502
503 #[inline]
505 pub fn payload(&self) -> &str {
506 &self.payload
507 }
508
509 #[inline]
511 pub fn previous_hash(&self) -> &[u8; 32] {
512 &self.previous_hash
513 }
514
515 #[inline]
517 pub fn entry_hash(&self) -> &[u8; 32] {
518 &self.entry_hash
519 }
520
521 #[inline]
523 pub fn root_agent_id(&self) -> Option<AgentId> {
524 self.root_agent_id
525 }
526
527 #[inline]
529 pub fn parent_agent_id(&self) -> Option<AgentId> {
530 self.parent_agent_id
531 }
532
533 #[inline]
535 pub fn team_id(&self) -> Option<&str> {
536 self.team_id.as_deref()
537 }
538
539 #[inline]
542 pub fn org_id(&self) -> Option<&str> {
543 self.org_id.as_deref()
544 }
545
546 #[inline]
548 pub fn delegation_reason(&self) -> Option<&str> {
549 self.delegation_reason.as_deref()
550 }
551
552 #[inline]
554 pub fn spawned_by_tool(&self) -> Option<&str> {
555 self.spawned_by_tool.as_deref()
556 }
557
558 #[inline]
560 pub fn depth(&self) -> Option<u32> {
561 self.depth
562 }
563
564 #[cfg(feature = "std")]
571 #[inline]
572 pub fn credential_findings(&self) -> &[crate::scanner::CredentialFinding] {
573 &self.credential_findings
574 }
575
576 #[cfg(feature = "std")]
582 #[inline]
583 pub fn redacted_payload(&self) -> Option<&str> {
584 self.redacted_payload.as_deref()
585 }
586
587 pub fn verify_integrity(&self) -> bool {
597 let lineage = Lineage {
598 root_agent_id: self.root_agent_id,
599 parent_agent_id: self.parent_agent_id,
600 team_id: self.team_id.clone(),
601 org_id: self.org_id.clone(),
602 delegation_reason: self.delegation_reason.clone(),
603 spawned_by_tool: self.spawned_by_tool.clone(),
604 depth: self.depth,
605 };
606 #[cfg(feature = "std")]
607 let redaction = Redaction {
608 credential_findings: self.credential_findings.clone(),
609 redacted_payload: self.redacted_payload.clone(),
610 };
611 let expected = Self::compute_hash(
612 self.seq,
613 self.timestamp_ns,
614 &self.event_type,
615 &self.agent_id,
616 &self.session_id,
617 &self.previous_hash,
618 &self.payload,
619 &lineage,
620 #[cfg(feature = "std")]
621 &redaction,
622 );
623 expected == self.entry_hash
624 }
625
626 #[allow(clippy::too_many_arguments)]
639 fn compute_hash(
640 seq: u64,
641 timestamp_ns: u64,
642 event_type: &AuditEventType,
643 agent_id: &AgentId,
644 session_id: &SessionId,
645 previous_hash: &[u8; 32],
646 payload: &str,
647 lineage: &Lineage,
648 #[cfg(feature = "std")] redaction: &Redaction,
649 ) -> [u8; 32] {
650 let mut hasher = Sha256::new();
651 hasher.update(seq.to_be_bytes());
652 hasher.update(timestamp_ns.to_be_bytes());
653 hasher.update((*event_type as u32).to_be_bytes());
654 hasher.update(agent_id.as_bytes());
655 hasher.update(session_id.as_bytes());
656 hasher.update(previous_hash);
657 hasher.update(payload.as_bytes());
658 if let Some(id) = &lineage.root_agent_id {
661 hasher.update(id.as_bytes());
662 }
663 if let Some(id) = &lineage.parent_agent_id {
664 hasher.update(id.as_bytes());
665 }
666 if let Some(s) = &lineage.team_id {
667 hasher.update((s.len() as u32).to_be_bytes());
668 hasher.update(s.as_bytes());
669 }
670 if let Some(s) = &lineage.delegation_reason {
671 hasher.update((s.len() as u32).to_be_bytes());
672 hasher.update(s.as_bytes());
673 }
674 if let Some(s) = &lineage.spawned_by_tool {
675 hasher.update((s.len() as u32).to_be_bytes());
676 hasher.update(s.as_bytes());
677 }
678 if let Some(d) = lineage.depth {
679 hasher.update(d.to_be_bytes());
680 }
681 if let Some(s) = &lineage.org_id {
684 hasher.update((s.len() as u32).to_be_bytes());
685 hasher.update(s.as_bytes());
686 }
687 #[cfg(feature = "std")]
690 {
691 if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
692 hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
693 for finding in &redaction.credential_findings {
694 hasher.update((finding.offset as u64).to_be_bytes());
695 hasher.update((finding.matched.len() as u32).to_be_bytes());
696 hasher.update(finding.matched.as_bytes());
697 }
698 if let Some(s) = &redaction.redacted_payload {
699 hasher.update([1u8]);
700 hasher.update((s.len() as u32).to_be_bytes());
701 hasher.update(s.as_bytes());
702 } else {
703 hasher.update([0u8]);
704 }
705 }
706 }
707 hasher.finalize().into()
708 }
709}
710
711#[cfg(feature = "std")]
729pub fn audit_entry_for_tool_dispatch(
730 seq: u64,
731 timestamp_ns: u64,
732 agent_id: AgentId,
733 session_id: SessionId,
734 placeholder_args: &serde_json::Value,
735 previous_hash: [u8; 32],
736) -> AuditEntry {
737 let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
738 String::from("{\"error\":\"failed to serialize placeholder args\"}")
742 });
743 AuditEntry::new(
744 seq,
745 timestamp_ns,
746 AuditEventType::ToolDispatched,
747 agent_id,
748 session_id,
749 payload,
750 previous_hash,
751 )
752}
753
754impl core::fmt::Display for AuditEntry {
759 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
766 write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
767 for b in self.agent_id.as_bytes() {
768 write!(f, "{:02x}", b)?;
769 }
770 write!(f, " session=")?;
771 for b in self.session_id.as_bytes() {
772 write!(f, "{:02x}", b)?;
773 }
774 write!(f, " event={}]", self.event_type.as_str())
775 }
776}
777
778#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum AuditLogError {
786 SequenceGap {
788 expected: u64,
790 got: u64,
792 },
793 HashChainBroken {
796 at_seq: u64,
798 },
799}
800
801impl core::fmt::Display for AuditLogError {
802 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803 match self {
804 Self::SequenceGap { expected, got } => {
805 write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
806 }
807 Self::HashChainBroken { at_seq } => {
808 write!(f, "audit log hash chain broken at seq={at_seq}")
809 }
810 }
811 }
812}
813
814pub struct AuditLog {
830 agent_id: AgentId,
831 session_id: SessionId,
832 entries: alloc::vec::Vec<AuditEntry>,
833 next_seq: u64,
835 last_hash: [u8; 32],
837}
838
839impl AuditLog {
840 pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
845 Self {
846 agent_id,
847 session_id,
848 entries: alloc::vec::Vec::new(),
849 next_seq: 0,
850 last_hash: [0u8; 32],
851 }
852 }
853
854 pub fn entries(&self) -> &[AuditEntry] {
856 &self.entries
857 }
858
859 pub fn len(&self) -> usize {
861 self.entries.len()
862 }
863
864 pub fn is_empty(&self) -> bool {
866 self.entries.is_empty()
867 }
868
869 pub fn agent_id(&self) -> AgentId {
871 self.agent_id
872 }
873
874 pub fn session_id(&self) -> SessionId {
876 self.session_id
877 }
878
879 pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
888 if entry.seq() != self.next_seq {
889 return Err(AuditLogError::SequenceGap {
890 expected: self.next_seq,
891 got: entry.seq(),
892 });
893 }
894 if entry.previous_hash() != &self.last_hash {
895 return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
896 }
897 self.last_hash = *entry.entry_hash();
898 self.next_seq += 1;
899 self.entries.push(entry);
900 Ok(())
901 }
902
903 pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
917 let entry = AuditEntry::new(
918 self.next_seq,
919 timestamp_ns,
920 event_type,
921 self.agent_id,
922 self.session_id,
923 payload,
924 self.last_hash,
925 );
926 self.push(entry).expect("next_entry invariant: push cannot fail");
929 self.entries.last().expect("entry was just pushed")
930 }
931
932 pub fn next_entry_with_lineage(
947 &mut self,
948 event_type: AuditEventType,
949 timestamp_ns: u64,
950 payload: String,
951 lineage: Lineage,
952 ) -> &AuditEntry {
953 let entry = AuditEntry::new_with_lineage(
954 self.next_seq,
955 timestamp_ns,
956 event_type,
957 self.agent_id,
958 self.session_id,
959 payload,
960 self.last_hash,
961 lineage,
962 );
963 self.push(entry)
964 .expect("next_entry_with_lineage invariant: push cannot fail");
965 self.entries.last().expect("entry was just pushed")
966 }
967
968 pub fn verify_chain(&self) -> bool {
979 let mut expected_prev_hash: [u8; 32] = [0u8; 32];
980
981 for (expected_seq, entry) in self.entries.iter().enumerate() {
982 if !entry.verify_integrity() {
983 return false;
984 }
985 if entry.seq() != expected_seq as u64 {
986 return false;
987 }
988 if entry.previous_hash() != &expected_prev_hash {
989 return false;
990 }
991 expected_prev_hash = *entry.entry_hash();
992 }
993 true
994 }
995}
996
997#[cfg(test)]
1002mod tests {
1003 use super::*;
1004
1005 const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
1007 const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
1008 const GENESIS_HASH: [u8; 32] = [0u8; 32];
1009
1010 fn make_entry(seq: u64) -> AuditEntry {
1011 AuditEntry::new(
1012 seq,
1013 1_714_222_134_000_000_000,
1014 AuditEventType::ToolCallIntercepted,
1015 AgentId::from_bytes(AGENT_BYTES),
1016 SessionId::from_bytes(SESSION_BYTES),
1017 alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
1018 GENESIS_HASH,
1019 )
1020 }
1021
1022 #[test]
1025 fn event_type_as_str_all_variants() {
1026 assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
1027 assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
1028 assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
1029 assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
1030 assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
1031 assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
1032 assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
1033 assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
1034 assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
1035 assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
1036 assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
1037 assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
1038 assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
1039 assert_eq!(
1040 AuditEventType::SandboxFilesystemBlocked.as_str(),
1041 "SandboxFilesystemBlocked"
1042 );
1043 assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
1044 assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
1045 assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
1046 }
1047
1048 #[test]
1049 fn event_type_discriminants_are_0_through_10() {
1050 assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
1051 assert_eq!(AuditEventType::PolicyViolation as u32, 1);
1052 assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
1053 assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
1054 assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
1055 assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
1056 assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
1057 assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
1058 assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
1059 assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
1060 assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
1061 assert_eq!(AuditEventType::ToolDispatched as u32, 13);
1062 assert_eq!(AuditEventType::SandboxStarted as u32, 16);
1063 assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
1064 assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
1065 assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
1066 assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
1067 }
1068
1069 #[test]
1070 fn event_type_variants_are_all_distinct() {
1071 let variants = [
1072 AuditEventType::ToolCallIntercepted,
1073 AuditEventType::PolicyViolation,
1074 AuditEventType::CredentialLeakBlocked,
1075 AuditEventType::ApprovalRequested,
1076 AuditEventType::ApprovalGranted,
1077 AuditEventType::ApprovalDenied,
1078 AuditEventType::BudgetLimitApproached,
1079 AuditEventType::BudgetLimitExceeded,
1080 AuditEventType::ApprovalTimedOut,
1081 AuditEventType::ApprovalRouted,
1082 AuditEventType::ApprovalEscalated,
1083 AuditEventType::ToolDispatched,
1084 AuditEventType::SandboxStarted,
1085 AuditEventType::SandboxFilesystemBlocked,
1086 AuditEventType::SandboxCpuTimeout,
1087 AuditEventType::SandboxOomKilled,
1088 AuditEventType::SandboxTerminated,
1089 ];
1090 for i in 0..variants.len() {
1091 for j in (i + 1)..variants.len() {
1092 assert_ne!(variants[i], variants[j]);
1093 }
1094 }
1095 }
1096
1097 #[test]
1100 fn new_produces_nonzero_entry_hash() {
1101 let entry = make_entry(0);
1102 assert_ne!(entry.entry_hash(), &[0u8; 32]);
1103 }
1104
1105 #[test]
1106 fn getters_return_correct_values() {
1107 let payload = alloc::string::String::from("{\"k\":\"v\"}");
1108 let entry = AuditEntry::new(
1109 42,
1110 999_000_000,
1111 AuditEventType::PolicyViolation,
1112 AgentId::from_bytes(AGENT_BYTES),
1113 SessionId::from_bytes(SESSION_BYTES),
1114 payload.clone(),
1115 GENESIS_HASH,
1116 );
1117 assert_eq!(entry.seq(), 42);
1118 assert_eq!(entry.timestamp_ns(), 999_000_000);
1119 assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
1120 assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
1121 assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
1122 assert_eq!(entry.payload(), "{\"k\":\"v\"}");
1123 assert_eq!(entry.previous_hash(), &GENESIS_HASH);
1124 }
1125
1126 #[test]
1127 fn genesis_entry_uses_zero_previous_hash() {
1128 let entry = make_entry(0);
1129 assert_eq!(entry.previous_hash(), &[0u8; 32]);
1130 }
1131
1132 #[test]
1135 fn verify_integrity_true_for_untampered_entry() {
1136 assert!(make_entry(0).verify_integrity());
1137 }
1138
1139 #[test]
1140 fn verify_integrity_false_after_seq_tamper() {
1141 let mut entry = make_entry(0);
1142 unsafe {
1144 let ptr = &mut entry.seq as *mut u64;
1145 *ptr = 999;
1146 }
1147 assert!(!entry.verify_integrity());
1148 }
1149
1150 #[test]
1151 fn verify_integrity_false_after_payload_tamper() {
1152 let mut entry = make_entry(0);
1153 unsafe {
1155 let ptr = entry.payload.as_mut_vec();
1156 if let Some(b) = ptr.first_mut() {
1157 *b = b'X';
1158 }
1159 }
1160 assert!(!entry.verify_integrity());
1161 }
1162
1163 #[test]
1164 fn verify_integrity_false_after_event_type_tamper() {
1165 let mut entry = make_entry(0);
1166 unsafe {
1168 let ptr = &mut entry.event_type as *mut AuditEventType;
1169 *ptr = AuditEventType::BudgetLimitExceeded;
1170 }
1171 assert!(!entry.verify_integrity());
1172 }
1173
1174 #[test]
1175 fn verify_integrity_false_after_previous_hash_tamper() {
1176 let mut entry = make_entry(0);
1177 unsafe {
1179 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1180 (*ptr)[0] = 0xFF;
1181 }
1182 assert!(!entry.verify_integrity());
1183 }
1184
1185 #[test]
1188 fn chained_entries_have_distinct_hashes() {
1189 let first = make_entry(0);
1190 let second = AuditEntry::new(
1191 1,
1192 1_714_222_134_000_000_001,
1193 AuditEventType::PolicyViolation,
1194 AgentId::from_bytes(AGENT_BYTES),
1195 SessionId::from_bytes(SESSION_BYTES),
1196 alloc::string::String::from("{\"rule\":\"deny\"}"),
1197 *first.entry_hash(),
1198 );
1199 assert_ne!(first.entry_hash(), second.entry_hash());
1200 assert_eq!(second.previous_hash(), first.entry_hash());
1201 assert!(second.verify_integrity());
1202 }
1203
1204 #[test]
1205 fn different_seq_produces_different_hash() {
1206 let a = make_entry(0);
1207 let b = make_entry(1);
1208 assert_ne!(a.entry_hash(), b.entry_hash());
1209 }
1210
1211 #[test]
1212 fn different_previous_hash_produces_different_entry_hash() {
1213 let prev_a = [0u8; 32];
1214 let mut prev_b = [0u8; 32];
1215 prev_b[0] = 1;
1216
1217 let a = AuditEntry::new(
1218 0,
1219 0,
1220 AuditEventType::ToolCallIntercepted,
1221 AgentId::from_bytes(AGENT_BYTES),
1222 SessionId::from_bytes(SESSION_BYTES),
1223 alloc::string::String::from("{}"),
1224 prev_a,
1225 );
1226 let b = AuditEntry::new(
1227 0,
1228 0,
1229 AuditEventType::ToolCallIntercepted,
1230 AgentId::from_bytes(AGENT_BYTES),
1231 SessionId::from_bytes(SESSION_BYTES),
1232 alloc::string::String::from("{}"),
1233 prev_b,
1234 );
1235 assert_ne!(a.entry_hash(), b.entry_hash());
1236 }
1237
1238 #[test]
1241 fn display_contains_seq_ts_and_event_name() {
1242 let entry = make_entry(7);
1243 let s = alloc::format!("{}", entry);
1244 assert!(s.starts_with('['));
1245 assert!(s.ends_with(']'));
1246 assert!(s.contains("seq=7"));
1247 assert!(s.contains("ts=1714222134000000000"));
1248 assert!(s.contains("event=ToolCallIntercepted"));
1249 }
1250
1251 #[test]
1252 fn display_contains_agent_and_session_hex() {
1253 let entry = make_entry(0);
1254 let s = alloc::format!("{}", entry);
1255 assert!(s.contains("agent=01020304"));
1257 assert!(s.contains("session=11121314"));
1259 }
1260
1261 #[test]
1262 fn display_does_not_contain_payload() {
1263 let entry = make_entry(0);
1264 let s = alloc::format!("{}", entry);
1265 assert!(!s.contains("bash"));
1266 }
1267
1268 #[test]
1269 fn display_round_trips_sandbox_event_names() {
1270 let sandbox_events = [
1274 (AuditEventType::SandboxStarted, "event=SandboxStarted]"),
1275 (
1276 AuditEventType::SandboxFilesystemBlocked,
1277 "event=SandboxFilesystemBlocked]",
1278 ),
1279 (AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
1280 (AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
1281 (AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
1282 ];
1283 for (event_type, expected_tail) in sandbox_events {
1284 let entry = AuditEntry::new(
1285 0,
1286 1_714_222_134_000_000_000,
1287 event_type,
1288 AgentId::from_bytes(AGENT_BYTES),
1289 SessionId::from_bytes(SESSION_BYTES),
1290 alloc::string::String::from("{}"),
1291 GENESIS_HASH,
1292 );
1293 let rendered = alloc::format!("{}", entry);
1294 assert!(
1295 rendered.ends_with(expected_tail),
1296 "Display for {:?} should end with `{}` but was `{}`",
1297 event_type,
1298 expected_tail,
1299 rendered,
1300 );
1301 }
1302 }
1303
1304 fn make_log() -> AuditLog {
1307 AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
1308 }
1309
1310 fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
1311 AuditEntry::new(
1312 seq,
1313 1_000_000_000,
1314 AuditEventType::ToolCallIntercepted,
1315 AgentId::from_bytes(AGENT_BYTES),
1316 SessionId::from_bytes(SESSION_BYTES),
1317 alloc::string::String::from("{}"),
1318 previous_hash,
1319 )
1320 }
1321
1322 #[test]
1325 fn push_genesis_entry_succeeds() {
1326 let mut log = make_log();
1327 let entry = make_valid_entry(0, GENESIS_HASH);
1328 assert!(log.push(entry).is_ok());
1329 assert_eq!(log.len(), 1);
1330 }
1331
1332 #[test]
1333 fn push_rejects_seq_gap_skipping_forward() {
1334 let mut log = make_log();
1335 let entry = make_valid_entry(2, GENESIS_HASH); let err = log.push(entry).unwrap_err();
1337 assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
1338 assert!(log.is_empty(), "log must be unmodified on error");
1339 }
1340
1341 #[test]
1342 fn push_rejects_seq_going_backward() {
1343 let mut log = make_log();
1344 let e0 = make_valid_entry(0, GENESIS_HASH);
1345 let hash0 = *e0.entry_hash();
1346 log.push(e0).unwrap();
1347
1348 let e_back = make_valid_entry(0, hash0); let err = log.push(e_back).unwrap_err();
1350 assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
1351 assert_eq!(log.len(), 1, "log must be unmodified on error");
1352 }
1353
1354 #[test]
1355 fn push_rejects_broken_hash_chain() {
1356 let mut log = make_log();
1357 let e0 = make_valid_entry(0, GENESIS_HASH);
1358 log.push(e0).unwrap();
1359
1360 let wrong_prev = [0xAB; 32]; let e1 = make_valid_entry(1, wrong_prev);
1362 let err = log.push(e1).unwrap_err();
1363 assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
1364 assert_eq!(log.len(), 1, "log must be unmodified on error");
1365 }
1366
1367 #[test]
1368 fn push_two_valid_entries_succeeds() {
1369 let mut log = make_log();
1370 let e0 = make_valid_entry(0, GENESIS_HASH);
1371 let hash0 = *e0.entry_hash();
1372 log.push(e0).unwrap();
1373
1374 let e1 = make_valid_entry(1, hash0);
1375 log.push(e1).unwrap();
1376
1377 assert_eq!(log.len(), 2);
1378 assert_eq!(log.entries()[0].seq(), 0);
1379 assert_eq!(log.entries()[1].seq(), 1);
1380 }
1381
1382 #[test]
1383 fn audit_log_error_display_sequence_gap() {
1384 let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
1385 let s = alloc::format!("{}", err);
1386 assert!(s.contains("expected seq=3"));
1387 assert!(s.contains("got seq=7"));
1388 }
1389
1390 #[test]
1391 fn audit_log_error_display_hash_chain_broken() {
1392 let err = AuditLogError::HashChainBroken { at_seq: 5 };
1393 let s = alloc::format!("{}", err);
1394 assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
1395 }
1396
1397 #[test]
1400 fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
1401 let mut log = make_log();
1402 let e = log.next_entry(
1403 AuditEventType::ToolCallIntercepted,
1404 1_000,
1405 alloc::string::String::from("{}"),
1406 );
1407 assert_eq!(e.seq(), 0);
1408 assert_eq!(e.previous_hash(), &GENESIS_HASH);
1409 assert!(e.verify_integrity());
1410 }
1411
1412 #[test]
1413 fn next_entry_auto_increments_seq() {
1414 let mut log = make_log();
1415 log.next_entry(
1416 AuditEventType::ToolCallIntercepted,
1417 1_000,
1418 alloc::string::String::from("{}"),
1419 );
1420 log.next_entry(
1421 AuditEventType::PolicyViolation,
1422 2_000,
1423 alloc::string::String::from("{}"),
1424 );
1425 log.next_entry(
1426 AuditEventType::ApprovalGranted,
1427 3_000,
1428 alloc::string::String::from("{}"),
1429 );
1430
1431 assert_eq!(log.len(), 3);
1432 assert_eq!(log.entries()[0].seq(), 0);
1433 assert_eq!(log.entries()[1].seq(), 1);
1434 assert_eq!(log.entries()[2].seq(), 2);
1435 }
1436
1437 #[test]
1438 fn next_entry_links_previous_hash_correctly() {
1439 let mut log = make_log();
1440 log.next_entry(
1441 AuditEventType::ToolCallIntercepted,
1442 1_000,
1443 alloc::string::String::from("{}"),
1444 );
1445 log.next_entry(
1446 AuditEventType::PolicyViolation,
1447 2_000,
1448 alloc::string::String::from("{}"),
1449 );
1450
1451 let e0_hash = *log.entries()[0].entry_hash();
1452 assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
1453 }
1454
1455 #[test]
1456 fn next_entry_mixed_with_push_works_correctly() {
1457 let mut log = make_log();
1458 log.next_entry(
1460 AuditEventType::ToolCallIntercepted,
1461 1_000,
1462 alloc::string::String::from("{}"),
1463 );
1464 let hash0 = *log.entries()[0].entry_hash();
1465
1466 let e1 = make_valid_entry(1, hash0);
1468 log.push(e1).unwrap();
1469
1470 log.next_entry(
1472 AuditEventType::ApprovalGranted,
1473 3_000,
1474 alloc::string::String::from("{}"),
1475 );
1476
1477 assert_eq!(log.len(), 3);
1478 assert_eq!(log.entries()[2].seq(), 2);
1479 assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
1480 }
1481
1482 #[test]
1483 fn next_entry_all_entries_pass_verify_integrity() {
1484 let mut log = make_log();
1485 for i in 0..5 {
1486 log.next_entry(
1487 AuditEventType::ToolCallIntercepted,
1488 i * 1_000,
1489 alloc::string::String::from("{}"),
1490 );
1491 }
1492 for entry in log.entries() {
1493 assert!(entry.verify_integrity());
1494 }
1495 }
1496
1497 #[test]
1500 fn verify_chain_empty_log_returns_true() {
1501 assert!(make_log().verify_chain());
1502 }
1503
1504 #[test]
1505 fn verify_chain_valid_log_returns_true() {
1506 let mut log = make_log();
1507 for i in 0..4 {
1508 log.next_entry(
1509 AuditEventType::ToolCallIntercepted,
1510 i * 1_000,
1511 alloc::string::String::from("{}"),
1512 );
1513 }
1514 assert!(log.verify_chain());
1515 }
1516
1517 #[test]
1518 fn verify_chain_false_after_unsafe_seq_tamper() {
1519 let mut log = make_log();
1520 log.next_entry(
1521 AuditEventType::ToolCallIntercepted,
1522 1_000,
1523 alloc::string::String::from("{}"),
1524 );
1525 log.next_entry(
1526 AuditEventType::PolicyViolation,
1527 2_000,
1528 alloc::string::String::from("{}"),
1529 );
1530
1531 unsafe {
1534 let entry = &mut *(log.entries.as_mut_ptr());
1535 let ptr = &mut entry.seq as *mut u64;
1536 *ptr = 99;
1537 }
1538 assert!(!log.verify_chain());
1539 }
1540
1541 #[test]
1542 fn verify_chain_false_after_unsafe_payload_tamper() {
1543 let mut log = make_log();
1544 log.next_entry(
1545 AuditEventType::ToolCallIntercepted,
1546 1_000,
1547 alloc::string::String::from("{}"),
1548 );
1549 log.next_entry(
1550 AuditEventType::PolicyViolation,
1551 2_000,
1552 alloc::string::String::from("{}"),
1553 );
1554
1555 unsafe {
1558 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1559 if let Some(b) = entry.payload.as_mut_vec().first_mut() {
1560 *b = b'X';
1561 }
1562 }
1563 assert!(!log.verify_chain());
1564 }
1565
1566 #[test]
1567 fn verify_chain_false_after_unsafe_previous_hash_tamper() {
1568 let mut log = make_log();
1569 log.next_entry(
1570 AuditEventType::ToolCallIntercepted,
1571 1_000,
1572 alloc::string::String::from("{}"),
1573 );
1574 log.next_entry(
1575 AuditEventType::PolicyViolation,
1576 2_000,
1577 alloc::string::String::from("{}"),
1578 );
1579
1580 unsafe {
1583 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1584 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1585 (*ptr)[0] = 0xFF;
1586 }
1587 assert!(!log.verify_chain());
1588 }
1589
1590 #[test]
1593 fn tool_dispatch_helper_emits_placeholder_form_payload() {
1594 let real_secret = "real-secret-abc-DEADBEEF-0001";
1596 let placeholder_args = serde_json::json!({
1597 "connection_string": "${DB_PASSWORD}"
1598 });
1599
1600 let entry = audit_entry_for_tool_dispatch(
1601 42,
1602 1_714_222_134_000_000_000,
1603 AgentId::from_bytes(AGENT_BYTES),
1604 SessionId::from_bytes(SESSION_BYTES),
1605 &placeholder_args,
1606 GENESIS_HASH,
1607 );
1608
1609 assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
1610 assert!(entry.payload().contains("${DB_PASSWORD}"));
1612 assert!(
1613 !entry.payload().contains(real_secret),
1614 "audit payload MUST NOT contain the resolved credential — placeholder-form contract"
1615 );
1616 }
1617}
1618
1619#[cfg(all(test, feature = "alloc", feature = "serde"))]
1620mod lineage_tests {
1621 use super::*;
1622
1623 const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
1624 const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
1625 const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
1626 const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);
1627
1628 fn base_entry() -> AuditEntry {
1629 AuditEntry::new(
1630 0,
1631 1_700_000_000_000_000_000,
1632 AuditEventType::ToolCallIntercepted,
1633 AGENT,
1634 SESSION,
1635 r#"{"tool":"bash"}"#.into(),
1636 [0u8; 32],
1637 )
1638 }
1639
1640 #[test]
1641 fn lineage_default_is_all_none() {
1642 let l = Lineage::default();
1643 assert!(l.root_agent_id.is_none());
1644 assert!(l.parent_agent_id.is_none());
1645 assert!(l.team_id.is_none());
1646 assert!(l.delegation_reason.is_none());
1647 assert!(l.spawned_by_tool.is_none());
1648 assert!(l.depth.is_none());
1649 }
1650
1651 #[test]
1652 fn new_with_empty_lineage_produces_same_hash_as_new() {
1653 let legacy = base_entry();
1654 let with_lineage = AuditEntry::new_with_lineage(
1655 0,
1656 1_700_000_000_000_000_000,
1657 AuditEventType::ToolCallIntercepted,
1658 AGENT,
1659 SESSION,
1660 r#"{"tool":"bash"}"#.into(),
1661 [0u8; 32],
1662 Lineage::default(),
1663 );
1664 assert_eq!(
1665 legacy.entry_hash(),
1666 with_lineage.entry_hash(),
1667 "Lineage::default() must not change the hash"
1668 );
1669 }
1670
1671 #[test]
1672 fn new_with_lineage_getters_return_correct_values() {
1673 let lineage = Lineage {
1674 root_agent_id: Some(ROOT),
1675 parent_agent_id: Some(PARENT),
1676 team_id: Some("team-alpha".into()),
1677 org_id: None,
1678 delegation_reason: Some("summarise".into()),
1679 spawned_by_tool: Some("langgraph".into()),
1680 depth: Some(2),
1681 };
1682 let entry = AuditEntry::new_with_lineage(
1683 0,
1684 1_000,
1685 AuditEventType::PolicyViolation,
1686 AGENT,
1687 SESSION,
1688 "{}".into(),
1689 [0u8; 32],
1690 lineage,
1691 );
1692 assert_eq!(entry.root_agent_id(), Some(ROOT));
1693 assert_eq!(entry.parent_agent_id(), Some(PARENT));
1694 assert_eq!(entry.team_id(), Some("team-alpha"));
1695 assert_eq!(entry.delegation_reason(), Some("summarise"));
1696 assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
1697 assert_eq!(entry.depth(), Some(2));
1698 }
1699
1700 #[test]
1701 fn verify_integrity_true_with_lineage() {
1702 let lineage = Lineage {
1703 root_agent_id: Some(ROOT),
1704 team_id: Some("ops".into()),
1705 depth: Some(1),
1706 ..Lineage::default()
1707 };
1708 let entry = AuditEntry::new_with_lineage(
1709 0,
1710 1_000,
1711 AuditEventType::ToolCallIntercepted,
1712 AGENT,
1713 SESSION,
1714 "{}".into(),
1715 [0u8; 32],
1716 lineage,
1717 );
1718 assert!(entry.verify_integrity());
1719 }
1720
1721 #[test]
1722 fn lineage_fields_change_hash() {
1723 let no_lineage = base_entry();
1724 let lineage = Lineage {
1725 depth: Some(1),
1726 ..Lineage::default()
1727 };
1728 let with_depth = AuditEntry::new_with_lineage(
1729 0,
1730 1_700_000_000_000_000_000,
1731 AuditEventType::ToolCallIntercepted,
1732 AGENT,
1733 SESSION,
1734 r#"{"tool":"bash"}"#.into(),
1735 [0u8; 32],
1736 lineage,
1737 );
1738 assert_ne!(
1739 no_lineage.entry_hash(),
1740 with_depth.entry_hash(),
1741 "A present lineage field must change the hash"
1742 );
1743 }
1744
1745 #[test]
1746 fn serde_round_trip_with_lineage() {
1747 let lineage = Lineage {
1748 root_agent_id: Some(ROOT),
1749 parent_agent_id: Some(PARENT),
1750 team_id: Some("t1".into()),
1751 org_id: Some("o1".into()),
1752 delegation_reason: Some("r".into()),
1753 spawned_by_tool: Some("s".into()),
1754 depth: Some(3),
1755 };
1756 let entry = AuditEntry::new_with_lineage(
1757 0,
1758 1_000,
1759 AuditEventType::ToolCallIntercepted,
1760 AGENT,
1761 SESSION,
1762 "{}".into(),
1763 [0u8; 32],
1764 lineage,
1765 );
1766 let json = serde_json::to_string(&entry).unwrap();
1767 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1768 assert_eq!(entry.entry_hash(), restored.entry_hash());
1769 assert_eq!(restored.root_agent_id(), Some(ROOT));
1770 assert_eq!(restored.depth(), Some(3));
1771 }
1772
1773 #[test]
1774 fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
1775 let pre_change_entry = AuditEntry::new(
1776 0,
1777 1_700_000_000_000_000_000,
1778 AuditEventType::ToolCallIntercepted,
1779 AGENT,
1780 SESSION,
1781 r#"{"tool":"bash"}"#.into(),
1782 [0u8; 32],
1783 );
1784 let json = serde_json::to_string(&pre_change_entry).unwrap();
1785 assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
1786 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1787 assert!(restored.root_agent_id().is_none());
1788 assert!(
1789 restored.verify_integrity(),
1790 "Legacy entries must still verify after adding lineage fields"
1791 );
1792 }
1793
1794 #[test]
1795 fn next_entry_with_lineage_links_chain() {
1796 let mut log = AuditLog::new(AGENT, SESSION);
1797 let lineage = Lineage {
1798 depth: Some(1),
1799 team_id: Some("t".into()),
1800 ..Lineage::default()
1801 };
1802 log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
1803 log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
1804 assert!(log.verify_chain());
1805 assert_eq!(log.len(), 2);
1806 }
1807}
1808
1809#[cfg(all(test, feature = "std", feature = "serde"))]
1810mod redaction_tests {
1811 use super::*;
1812 use crate::scanner::CredentialScanner;
1813
1814 const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
1815 const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);
1816
1817 const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
1819
1820 fn build_redaction_for_fake_secret() -> Redaction {
1821 let scanner = CredentialScanner::new();
1822 let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
1823 assert!(
1824 !scan.findings.is_empty(),
1825 "scanner must detect the synthetic AWS access key — fixture invariant",
1826 );
1827 let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
1828 Redaction {
1829 credential_findings: scan.findings,
1830 redacted_payload: Some(redacted),
1831 }
1832 }
1833
1834 #[test]
1835 fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
1836 let redaction = build_redaction_for_fake_secret();
1837 let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
1839 let entry = AuditEntry::new_with_lineage_and_redaction(
1840 0,
1841 1_700_000_000_000_000_000,
1842 AuditEventType::CredentialLeakBlocked,
1843 AGENT,
1844 SESSION,
1845 payload,
1846 [0u8; 32],
1847 Lineage::default(),
1848 redaction,
1849 );
1850
1851 let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");
1852
1853 assert!(
1857 !serialized.contains(FAKE_AWS_ACCESS_KEY),
1858 "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
1859 );
1860
1861 assert!(
1863 serialized.contains("[REDACTED:AwsAccessKey]"),
1864 "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
1865 );
1866
1867 assert!(
1869 entry.verify_integrity(),
1870 "verify_integrity must pass on a freshly constructed redacted entry",
1871 );
1872 }
1873
1874 #[test]
1875 fn redaction_default_preserves_legacy_hash() {
1876 let payload = String::from(r#"{"tool":"bash"}"#);
1880 let legacy = AuditEntry::new(
1881 0,
1882 1_700_000_000_000_000_000,
1883 AuditEventType::ToolCallIntercepted,
1884 AGENT,
1885 SESSION,
1886 payload.clone(),
1887 [0u8; 32],
1888 );
1889 let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
1890 0,
1891 1_700_000_000_000_000_000,
1892 AuditEventType::ToolCallIntercepted,
1893 AGENT,
1894 SESSION,
1895 payload,
1896 [0u8; 32],
1897 Lineage::default(),
1898 Redaction::default(),
1899 );
1900 assert_eq!(
1901 legacy.entry_hash(),
1902 with_default_redaction.entry_hash(),
1903 "Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
1904 );
1905 }
1906}