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))]
25#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
26pub enum AuditEventType {
27 ToolCallIntercepted = 0,
29 PolicyViolation = 1,
31 CredentialLeakBlocked = 2,
33 ApprovalRequested = 3,
35 ApprovalGranted = 4,
37 ApprovalDenied = 5,
39 BudgetLimitApproached = 6,
41 BudgetLimitExceeded = 7,
43 ApprovalTimedOut = 8,
45 ApprovalRouted = 9,
47 ApprovalEscalated = 10,
49 AgentForceDeregistered = 11,
51 MessageBlocked = 12,
53 ToolDispatched = 13,
59 A2ACallIntercepted = 14,
66 A2AImpersonationAttempted = 15,
73 SandboxStarted = 16,
85 SandboxFilesystemBlocked = 17,
92 SandboxCpuTimeout = 18,
99 SandboxOomKilled = 19,
109 SandboxTerminated = 20,
115 SandboxHostFnRateLimited = 21,
126}
127
128impl AuditEventType {
129 pub fn as_str(&self) -> &'static str {
133 match self {
134 Self::ToolCallIntercepted => "ToolCallIntercepted",
135 Self::PolicyViolation => "PolicyViolation",
136 Self::CredentialLeakBlocked => "CredentialLeakBlocked",
137 Self::ApprovalRequested => "ApprovalRequested",
138 Self::ApprovalGranted => "ApprovalGranted",
139 Self::ApprovalDenied => "ApprovalDenied",
140 Self::BudgetLimitApproached => "BudgetLimitApproached",
141 Self::BudgetLimitExceeded => "BudgetLimitExceeded",
142 Self::ApprovalTimedOut => "ApprovalTimedOut",
143 Self::ApprovalRouted => "ApprovalRouted",
144 Self::ApprovalEscalated => "ApprovalEscalated",
145 Self::AgentForceDeregistered => "AgentForceDeregistered",
146 Self::MessageBlocked => "MessageBlocked",
147 Self::ToolDispatched => "ToolDispatched",
148 Self::A2ACallIntercepted => "A2ACallIntercepted",
149 Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
150 Self::SandboxStarted => "SandboxStarted",
151 Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
152 Self::SandboxCpuTimeout => "SandboxCpuTimeout",
153 Self::SandboxOomKilled => "SandboxOomKilled",
154 Self::SandboxTerminated => "SandboxTerminated",
155 Self::SandboxHostFnRateLimited => "SandboxHostFnRateLimited",
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Default)]
171#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
172pub struct Lineage {
173 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
175 pub root_agent_id: Option<AgentId>,
176 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
178 pub parent_agent_id: Option<AgentId>,
179 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
181 pub team_id: Option<String>,
182 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
189 pub org_id: Option<String>,
190 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
192 pub delegation_reason: Option<String>,
193 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
195 pub spawned_by_tool: Option<String>,
196 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
198 pub depth: Option<u32>,
199}
200
201#[cfg(feature = "std")]
211use aa_security::Redaction;
212
213#[derive(Debug, Clone, PartialEq, Eq)]
237#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
238pub struct AuditEntry {
239 seq: u64,
240 timestamp_ns: u64,
241 event_type: AuditEventType,
242 agent_id: AgentId,
243 session_id: SessionId,
244 payload: String,
245 previous_hash: [u8; 32],
246 entry_hash: [u8; 32],
247 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
248 root_agent_id: Option<AgentId>,
249 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
250 parent_agent_id: Option<AgentId>,
251 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
252 team_id: Option<String>,
253 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
255 org_id: Option<String>,
256 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
257 delegation_reason: Option<String>,
258 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
259 spawned_by_tool: Option<String>,
260 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
261 depth: Option<u32>,
262 #[cfg(feature = "std")]
263 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
264 credential_findings: alloc::vec::Vec<aa_security::CredentialFinding>,
265 #[cfg(feature = "std")]
266 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
267 redacted_payload: Option<String>,
268}
269
270impl AuditEntry {
271 pub fn new(
303 seq: u64,
304 timestamp_ns: u64,
305 event_type: AuditEventType,
306 agent_id: AgentId,
307 session_id: SessionId,
308 payload: String,
309 previous_hash: [u8; 32],
310 ) -> Self {
311 let entry_hash = Self::compute_hash(
312 seq,
313 timestamp_ns,
314 &event_type,
315 &agent_id,
316 &session_id,
317 &previous_hash,
318 &payload,
319 &Lineage::default(),
320 #[cfg(feature = "std")]
321 &Redaction::default(),
322 );
323 Self {
324 seq,
325 timestamp_ns,
326 event_type,
327 agent_id,
328 session_id,
329 payload,
330 previous_hash,
331 entry_hash,
332 root_agent_id: None,
333 parent_agent_id: None,
334 team_id: None,
335 org_id: None,
336 delegation_reason: None,
337 spawned_by_tool: None,
338 depth: None,
339 #[cfg(feature = "std")]
340 credential_findings: alloc::vec::Vec::new(),
341 #[cfg(feature = "std")]
342 redacted_payload: None,
343 }
344 }
345
346 #[allow(clippy::too_many_arguments)]
353 pub fn new_with_lineage(
354 seq: u64,
355 timestamp_ns: u64,
356 event_type: AuditEventType,
357 agent_id: AgentId,
358 session_id: SessionId,
359 payload: String,
360 previous_hash: [u8; 32],
361 lineage: Lineage,
362 ) -> Self {
363 let entry_hash = Self::compute_hash(
364 seq,
365 timestamp_ns,
366 &event_type,
367 &agent_id,
368 &session_id,
369 &previous_hash,
370 &payload,
371 &lineage,
372 #[cfg(feature = "std")]
373 &Redaction::default(),
374 );
375 Self {
376 seq,
377 timestamp_ns,
378 event_type,
379 agent_id,
380 session_id,
381 payload,
382 previous_hash,
383 entry_hash,
384 root_agent_id: lineage.root_agent_id,
385 parent_agent_id: lineage.parent_agent_id,
386 team_id: lineage.team_id,
387 org_id: lineage.org_id,
388 delegation_reason: lineage.delegation_reason,
389 spawned_by_tool: lineage.spawned_by_tool,
390 depth: lineage.depth,
391 #[cfg(feature = "std")]
392 credential_findings: alloc::vec::Vec::new(),
393 #[cfg(feature = "std")]
394 redacted_payload: None,
395 }
396 }
397
398 #[cfg(feature = "std")]
410 #[allow(clippy::too_many_arguments)]
411 pub fn new_with_lineage_and_redaction(
412 seq: u64,
413 timestamp_ns: u64,
414 event_type: AuditEventType,
415 agent_id: AgentId,
416 session_id: SessionId,
417 payload: String,
418 previous_hash: [u8; 32],
419 lineage: Lineage,
420 redaction: Redaction,
421 ) -> Self {
422 let entry_hash = Self::compute_hash(
423 seq,
424 timestamp_ns,
425 &event_type,
426 &agent_id,
427 &session_id,
428 &previous_hash,
429 &payload,
430 &lineage,
431 &redaction,
432 );
433 Self {
434 seq,
435 timestamp_ns,
436 event_type,
437 agent_id,
438 session_id,
439 payload,
440 previous_hash,
441 entry_hash,
442 root_agent_id: lineage.root_agent_id,
443 parent_agent_id: lineage.parent_agent_id,
444 team_id: lineage.team_id,
445 org_id: lineage.org_id,
446 delegation_reason: lineage.delegation_reason,
447 spawned_by_tool: lineage.spawned_by_tool,
448 depth: lineage.depth,
449 credential_findings: redaction.credential_findings,
450 redacted_payload: redaction.redacted_payload,
451 }
452 }
453
454 #[inline]
460 pub fn seq(&self) -> u64 {
461 self.seq
462 }
463
464 #[inline]
466 pub fn timestamp_ns(&self) -> u64 {
467 self.timestamp_ns
468 }
469
470 #[inline]
472 pub fn event_type(&self) -> AuditEventType {
473 self.event_type
474 }
475
476 #[inline]
478 pub fn agent_id(&self) -> AgentId {
479 self.agent_id
480 }
481
482 #[inline]
484 pub fn session_id(&self) -> SessionId {
485 self.session_id
486 }
487
488 #[inline]
490 pub fn payload(&self) -> &str {
491 &self.payload
492 }
493
494 #[inline]
496 pub fn previous_hash(&self) -> &[u8; 32] {
497 &self.previous_hash
498 }
499
500 #[inline]
502 pub fn entry_hash(&self) -> &[u8; 32] {
503 &self.entry_hash
504 }
505
506 #[inline]
508 pub fn root_agent_id(&self) -> Option<AgentId> {
509 self.root_agent_id
510 }
511
512 #[inline]
514 pub fn parent_agent_id(&self) -> Option<AgentId> {
515 self.parent_agent_id
516 }
517
518 #[inline]
520 pub fn team_id(&self) -> Option<&str> {
521 self.team_id.as_deref()
522 }
523
524 #[inline]
527 pub fn org_id(&self) -> Option<&str> {
528 self.org_id.as_deref()
529 }
530
531 #[inline]
533 pub fn delegation_reason(&self) -> Option<&str> {
534 self.delegation_reason.as_deref()
535 }
536
537 #[inline]
539 pub fn spawned_by_tool(&self) -> Option<&str> {
540 self.spawned_by_tool.as_deref()
541 }
542
543 #[inline]
545 pub fn depth(&self) -> Option<u32> {
546 self.depth
547 }
548
549 #[cfg(feature = "std")]
556 #[inline]
557 pub fn credential_findings(&self) -> &[aa_security::CredentialFinding] {
558 &self.credential_findings
559 }
560
561 #[cfg(feature = "std")]
567 #[inline]
568 pub fn redacted_payload(&self) -> Option<&str> {
569 self.redacted_payload.as_deref()
570 }
571
572 pub fn verify_integrity(&self) -> bool {
582 let lineage = Lineage {
583 root_agent_id: self.root_agent_id,
584 parent_agent_id: self.parent_agent_id,
585 team_id: self.team_id.clone(),
586 org_id: self.org_id.clone(),
587 delegation_reason: self.delegation_reason.clone(),
588 spawned_by_tool: self.spawned_by_tool.clone(),
589 depth: self.depth,
590 };
591 #[cfg(feature = "std")]
592 let redaction = Redaction {
593 credential_findings: self.credential_findings.clone(),
594 redacted_payload: self.redacted_payload.clone(),
595 };
596 let expected = Self::compute_hash(
597 self.seq,
598 self.timestamp_ns,
599 &self.event_type,
600 &self.agent_id,
601 &self.session_id,
602 &self.previous_hash,
603 &self.payload,
604 &lineage,
605 #[cfg(feature = "std")]
606 &redaction,
607 );
608 expected == self.entry_hash
609 }
610
611 #[allow(clippy::too_many_arguments)]
624 fn compute_hash(
625 seq: u64,
626 timestamp_ns: u64,
627 event_type: &AuditEventType,
628 agent_id: &AgentId,
629 session_id: &SessionId,
630 previous_hash: &[u8; 32],
631 payload: &str,
632 lineage: &Lineage,
633 #[cfg(feature = "std")] redaction: &Redaction,
634 ) -> [u8; 32] {
635 let mut hasher = Sha256::new();
636 hasher.update(seq.to_be_bytes());
637 hasher.update(timestamp_ns.to_be_bytes());
638 hasher.update((*event_type as u32).to_be_bytes());
639 hasher.update(agent_id.as_bytes());
640 hasher.update(session_id.as_bytes());
641 hasher.update(previous_hash);
642 hasher.update(payload.as_bytes());
643 if let Some(id) = &lineage.root_agent_id {
646 hasher.update(id.as_bytes());
647 }
648 if let Some(id) = &lineage.parent_agent_id {
649 hasher.update(id.as_bytes());
650 }
651 if let Some(s) = &lineage.team_id {
652 hasher.update((s.len() as u32).to_be_bytes());
653 hasher.update(s.as_bytes());
654 }
655 if let Some(s) = &lineage.delegation_reason {
656 hasher.update((s.len() as u32).to_be_bytes());
657 hasher.update(s.as_bytes());
658 }
659 if let Some(s) = &lineage.spawned_by_tool {
660 hasher.update((s.len() as u32).to_be_bytes());
661 hasher.update(s.as_bytes());
662 }
663 if let Some(d) = lineage.depth {
664 hasher.update(d.to_be_bytes());
665 }
666 if let Some(s) = &lineage.org_id {
669 hasher.update((s.len() as u32).to_be_bytes());
670 hasher.update(s.as_bytes());
671 }
672 #[cfg(feature = "std")]
675 {
676 if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
677 hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
678 for finding in &redaction.credential_findings {
679 hasher.update((finding.offset as u64).to_be_bytes());
680 hasher.update((finding.matched.len() as u32).to_be_bytes());
681 hasher.update(finding.matched.as_bytes());
682 }
683 if let Some(s) = &redaction.redacted_payload {
684 hasher.update([1u8]);
685 hasher.update((s.len() as u32).to_be_bytes());
686 hasher.update(s.as_bytes());
687 } else {
688 hasher.update([0u8]);
689 }
690 }
691 }
692 hasher.finalize().into()
693 }
694}
695
696#[cfg(feature = "std")]
714pub fn audit_entry_for_tool_dispatch(
715 seq: u64,
716 timestamp_ns: u64,
717 agent_id: AgentId,
718 session_id: SessionId,
719 placeholder_args: &serde_json::Value,
720 previous_hash: [u8; 32],
721) -> AuditEntry {
722 let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
723 String::from("{\"error\":\"failed to serialize placeholder args\"}")
727 });
728 AuditEntry::new(
729 seq,
730 timestamp_ns,
731 AuditEventType::ToolDispatched,
732 agent_id,
733 session_id,
734 payload,
735 previous_hash,
736 )
737}
738
739impl core::fmt::Display for AuditEntry {
744 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
751 write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
752 for b in self.agent_id.as_bytes() {
753 write!(f, "{:02x}", b)?;
754 }
755 write!(f, " session=")?;
756 for b in self.session_id.as_bytes() {
757 write!(f, "{:02x}", b)?;
758 }
759 write!(f, " event={}]", self.event_type.as_str())
760 }
761}
762
763#[derive(Debug, Clone, PartialEq, Eq)]
770pub enum AuditLogError {
771 SequenceGap {
773 expected: u64,
775 got: u64,
777 },
778 HashChainBroken {
781 at_seq: u64,
783 },
784}
785
786impl core::fmt::Display for AuditLogError {
787 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
788 match self {
789 Self::SequenceGap { expected, got } => {
790 write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
791 }
792 Self::HashChainBroken { at_seq } => {
793 write!(f, "audit log hash chain broken at seq={at_seq}")
794 }
795 }
796 }
797}
798
799pub struct AuditLog {
815 agent_id: AgentId,
816 session_id: SessionId,
817 entries: alloc::vec::Vec<AuditEntry>,
818 next_seq: u64,
820 last_hash: [u8; 32],
822}
823
824impl AuditLog {
825 pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
830 Self {
831 agent_id,
832 session_id,
833 entries: alloc::vec::Vec::new(),
834 next_seq: 0,
835 last_hash: [0u8; 32],
836 }
837 }
838
839 pub fn entries(&self) -> &[AuditEntry] {
841 &self.entries
842 }
843
844 pub fn len(&self) -> usize {
846 self.entries.len()
847 }
848
849 pub fn is_empty(&self) -> bool {
851 self.entries.is_empty()
852 }
853
854 pub fn agent_id(&self) -> AgentId {
856 self.agent_id
857 }
858
859 pub fn session_id(&self) -> SessionId {
861 self.session_id
862 }
863
864 pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
873 if entry.seq() != self.next_seq {
874 return Err(AuditLogError::SequenceGap {
875 expected: self.next_seq,
876 got: entry.seq(),
877 });
878 }
879 if entry.previous_hash() != &self.last_hash {
880 return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
881 }
882 self.last_hash = *entry.entry_hash();
883 self.next_seq += 1;
884 self.entries.push(entry);
885 Ok(())
886 }
887
888 pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
902 let entry = AuditEntry::new(
903 self.next_seq,
904 timestamp_ns,
905 event_type,
906 self.agent_id,
907 self.session_id,
908 payload,
909 self.last_hash,
910 );
911 self.push(entry).expect("next_entry invariant: push cannot fail");
914 self.entries.last().expect("entry was just pushed")
915 }
916
917 pub fn next_entry_with_lineage(
932 &mut self,
933 event_type: AuditEventType,
934 timestamp_ns: u64,
935 payload: String,
936 lineage: Lineage,
937 ) -> &AuditEntry {
938 let entry = AuditEntry::new_with_lineage(
939 self.next_seq,
940 timestamp_ns,
941 event_type,
942 self.agent_id,
943 self.session_id,
944 payload,
945 self.last_hash,
946 lineage,
947 );
948 self.push(entry)
949 .expect("next_entry_with_lineage invariant: push cannot fail");
950 self.entries.last().expect("entry was just pushed")
951 }
952
953 pub fn verify_chain(&self) -> bool {
964 let mut expected_prev_hash: [u8; 32] = [0u8; 32];
965
966 for (expected_seq, entry) in self.entries.iter().enumerate() {
967 if !entry.verify_integrity() {
968 return false;
969 }
970 if entry.seq() != expected_seq as u64 {
971 return false;
972 }
973 if entry.previous_hash() != &expected_prev_hash {
974 return false;
975 }
976 expected_prev_hash = *entry.entry_hash();
977 }
978 true
979 }
980}
981
982#[cfg(test)]
987mod tests {
988 use super::*;
989
990 const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
992 const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
993 const GENESIS_HASH: [u8; 32] = [0u8; 32];
994
995 fn make_entry(seq: u64) -> AuditEntry {
996 AuditEntry::new(
997 seq,
998 1_714_222_134_000_000_000,
999 AuditEventType::ToolCallIntercepted,
1000 AgentId::from_bytes(AGENT_BYTES),
1001 SessionId::from_bytes(SESSION_BYTES),
1002 alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
1003 GENESIS_HASH,
1004 )
1005 }
1006
1007 #[test]
1010 fn event_type_as_str_all_variants() {
1011 assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
1012 assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
1013 assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
1014 assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
1015 assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
1016 assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
1017 assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
1018 assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
1019 assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
1020 assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
1021 assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
1022 assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
1023 assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
1024 assert_eq!(
1025 AuditEventType::SandboxFilesystemBlocked.as_str(),
1026 "SandboxFilesystemBlocked"
1027 );
1028 assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
1029 assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
1030 assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
1031 assert_eq!(
1032 AuditEventType::SandboxHostFnRateLimited.as_str(),
1033 "SandboxHostFnRateLimited"
1034 );
1035 }
1036
1037 #[test]
1038 fn event_type_discriminants_are_0_through_10() {
1039 assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
1040 assert_eq!(AuditEventType::PolicyViolation as u32, 1);
1041 assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
1042 assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
1043 assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
1044 assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
1045 assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
1046 assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
1047 assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
1048 assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
1049 assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
1050 assert_eq!(AuditEventType::ToolDispatched as u32, 13);
1051 assert_eq!(AuditEventType::SandboxStarted as u32, 16);
1052 assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
1053 assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
1054 assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
1055 assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
1056 assert_eq!(AuditEventType::SandboxHostFnRateLimited as u32, 21);
1057 }
1058
1059 #[test]
1060 fn event_type_variants_are_all_distinct() {
1061 let variants = [
1062 AuditEventType::ToolCallIntercepted,
1063 AuditEventType::PolicyViolation,
1064 AuditEventType::CredentialLeakBlocked,
1065 AuditEventType::ApprovalRequested,
1066 AuditEventType::ApprovalGranted,
1067 AuditEventType::ApprovalDenied,
1068 AuditEventType::BudgetLimitApproached,
1069 AuditEventType::BudgetLimitExceeded,
1070 AuditEventType::ApprovalTimedOut,
1071 AuditEventType::ApprovalRouted,
1072 AuditEventType::ApprovalEscalated,
1073 AuditEventType::ToolDispatched,
1074 AuditEventType::SandboxStarted,
1075 AuditEventType::SandboxFilesystemBlocked,
1076 AuditEventType::SandboxCpuTimeout,
1077 AuditEventType::SandboxOomKilled,
1078 AuditEventType::SandboxTerminated,
1079 AuditEventType::SandboxHostFnRateLimited,
1080 ];
1081 for i in 0..variants.len() {
1082 for j in (i + 1)..variants.len() {
1083 assert_ne!(variants[i], variants[j]);
1084 }
1085 }
1086 }
1087
1088 #[test]
1091 fn new_produces_nonzero_entry_hash() {
1092 let entry = make_entry(0);
1093 assert_ne!(entry.entry_hash(), &[0u8; 32]);
1094 }
1095
1096 #[test]
1097 fn getters_return_correct_values() {
1098 let payload = alloc::string::String::from("{\"k\":\"v\"}");
1099 let entry = AuditEntry::new(
1100 42,
1101 999_000_000,
1102 AuditEventType::PolicyViolation,
1103 AgentId::from_bytes(AGENT_BYTES),
1104 SessionId::from_bytes(SESSION_BYTES),
1105 payload.clone(),
1106 GENESIS_HASH,
1107 );
1108 assert_eq!(entry.seq(), 42);
1109 assert_eq!(entry.timestamp_ns(), 999_000_000);
1110 assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
1111 assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
1112 assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
1113 assert_eq!(entry.payload(), "{\"k\":\"v\"}");
1114 assert_eq!(entry.previous_hash(), &GENESIS_HASH);
1115 }
1116
1117 #[test]
1118 fn genesis_entry_uses_zero_previous_hash() {
1119 let entry = make_entry(0);
1120 assert_eq!(entry.previous_hash(), &[0u8; 32]);
1121 }
1122
1123 #[test]
1126 fn verify_integrity_true_for_untampered_entry() {
1127 assert!(make_entry(0).verify_integrity());
1128 }
1129
1130 #[test]
1131 fn verify_integrity_false_after_seq_tamper() {
1132 let mut entry = make_entry(0);
1133 unsafe {
1135 let ptr = &mut entry.seq as *mut u64;
1136 *ptr = 999;
1137 }
1138 assert!(!entry.verify_integrity());
1139 }
1140
1141 #[test]
1142 fn verify_integrity_false_after_payload_tamper() {
1143 let mut entry = make_entry(0);
1144 unsafe {
1146 let ptr = entry.payload.as_mut_vec();
1147 if let Some(b) = ptr.first_mut() {
1148 *b = b'X';
1149 }
1150 }
1151 assert!(!entry.verify_integrity());
1152 }
1153
1154 #[test]
1155 fn verify_integrity_false_after_event_type_tamper() {
1156 let mut entry = make_entry(0);
1157 unsafe {
1159 let ptr = &mut entry.event_type as *mut AuditEventType;
1160 *ptr = AuditEventType::BudgetLimitExceeded;
1161 }
1162 assert!(!entry.verify_integrity());
1163 }
1164
1165 #[test]
1166 fn verify_integrity_false_after_previous_hash_tamper() {
1167 let mut entry = make_entry(0);
1168 unsafe {
1170 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1171 (*ptr)[0] = 0xFF;
1172 }
1173 assert!(!entry.verify_integrity());
1174 }
1175
1176 #[test]
1179 fn chained_entries_have_distinct_hashes() {
1180 let first = make_entry(0);
1181 let second = AuditEntry::new(
1182 1,
1183 1_714_222_134_000_000_001,
1184 AuditEventType::PolicyViolation,
1185 AgentId::from_bytes(AGENT_BYTES),
1186 SessionId::from_bytes(SESSION_BYTES),
1187 alloc::string::String::from("{\"rule\":\"deny\"}"),
1188 *first.entry_hash(),
1189 );
1190 assert_ne!(first.entry_hash(), second.entry_hash());
1191 assert_eq!(second.previous_hash(), first.entry_hash());
1192 assert!(second.verify_integrity());
1193 }
1194
1195 #[test]
1196 fn different_seq_produces_different_hash() {
1197 let a = make_entry(0);
1198 let b = make_entry(1);
1199 assert_ne!(a.entry_hash(), b.entry_hash());
1200 }
1201
1202 #[test]
1203 fn different_previous_hash_produces_different_entry_hash() {
1204 let prev_a = [0u8; 32];
1205 let mut prev_b = [0u8; 32];
1206 prev_b[0] = 1;
1207
1208 let a = AuditEntry::new(
1209 0,
1210 0,
1211 AuditEventType::ToolCallIntercepted,
1212 AgentId::from_bytes(AGENT_BYTES),
1213 SessionId::from_bytes(SESSION_BYTES),
1214 alloc::string::String::from("{}"),
1215 prev_a,
1216 );
1217 let b = 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_b,
1225 );
1226 assert_ne!(a.entry_hash(), b.entry_hash());
1227 }
1228
1229 #[test]
1232 fn display_contains_seq_ts_and_event_name() {
1233 let entry = make_entry(7);
1234 let s = alloc::format!("{}", entry);
1235 assert!(s.starts_with('['));
1236 assert!(s.ends_with(']'));
1237 assert!(s.contains("seq=7"));
1238 assert!(s.contains("ts=1714222134000000000"));
1239 assert!(s.contains("event=ToolCallIntercepted"));
1240 }
1241
1242 #[test]
1243 fn display_contains_agent_and_session_hex() {
1244 let entry = make_entry(0);
1245 let s = alloc::format!("{}", entry);
1246 assert!(s.contains("agent=01020304"));
1248 assert!(s.contains("session=11121314"));
1250 }
1251
1252 #[test]
1253 fn display_does_not_contain_payload() {
1254 let entry = make_entry(0);
1255 let s = alloc::format!("{}", entry);
1256 assert!(!s.contains("bash"));
1257 }
1258
1259 #[test]
1260 fn display_round_trips_sandbox_event_names() {
1261 let sandbox_events = [
1265 (AuditEventType::SandboxStarted, "event=SandboxStarted]"),
1266 (
1267 AuditEventType::SandboxFilesystemBlocked,
1268 "event=SandboxFilesystemBlocked]",
1269 ),
1270 (AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
1271 (AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
1272 (AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
1273 (
1274 AuditEventType::SandboxHostFnRateLimited,
1275 "event=SandboxHostFnRateLimited]",
1276 ),
1277 ];
1278 for (event_type, expected_tail) in sandbox_events {
1279 let entry = AuditEntry::new(
1280 0,
1281 1_714_222_134_000_000_000,
1282 event_type,
1283 AgentId::from_bytes(AGENT_BYTES),
1284 SessionId::from_bytes(SESSION_BYTES),
1285 alloc::string::String::from("{}"),
1286 GENESIS_HASH,
1287 );
1288 let rendered = alloc::format!("{}", entry);
1289 assert!(
1290 rendered.ends_with(expected_tail),
1291 "Display for {:?} should end with `{}` but was `{}`",
1292 event_type,
1293 expected_tail,
1294 rendered,
1295 );
1296 }
1297 }
1298
1299 fn make_log() -> AuditLog {
1302 AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
1303 }
1304
1305 fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
1306 AuditEntry::new(
1307 seq,
1308 1_000_000_000,
1309 AuditEventType::ToolCallIntercepted,
1310 AgentId::from_bytes(AGENT_BYTES),
1311 SessionId::from_bytes(SESSION_BYTES),
1312 alloc::string::String::from("{}"),
1313 previous_hash,
1314 )
1315 }
1316
1317 #[test]
1320 fn push_genesis_entry_succeeds() {
1321 let mut log = make_log();
1322 let entry = make_valid_entry(0, GENESIS_HASH);
1323 assert!(log.push(entry).is_ok());
1324 assert_eq!(log.len(), 1);
1325 }
1326
1327 #[test]
1328 fn push_rejects_seq_gap_skipping_forward() {
1329 let mut log = make_log();
1330 let entry = make_valid_entry(2, GENESIS_HASH); let err = log.push(entry).unwrap_err();
1332 assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
1333 assert!(log.is_empty(), "log must be unmodified on error");
1334 }
1335
1336 #[test]
1337 fn push_rejects_seq_going_backward() {
1338 let mut log = make_log();
1339 let e0 = make_valid_entry(0, GENESIS_HASH);
1340 let hash0 = *e0.entry_hash();
1341 log.push(e0).unwrap();
1342
1343 let e_back = make_valid_entry(0, hash0); let err = log.push(e_back).unwrap_err();
1345 assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
1346 assert_eq!(log.len(), 1, "log must be unmodified on error");
1347 }
1348
1349 #[test]
1350 fn push_rejects_broken_hash_chain() {
1351 let mut log = make_log();
1352 let e0 = make_valid_entry(0, GENESIS_HASH);
1353 log.push(e0).unwrap();
1354
1355 let wrong_prev = [0xAB; 32]; let e1 = make_valid_entry(1, wrong_prev);
1357 let err = log.push(e1).unwrap_err();
1358 assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
1359 assert_eq!(log.len(), 1, "log must be unmodified on error");
1360 }
1361
1362 #[test]
1363 fn push_two_valid_entries_succeeds() {
1364 let mut log = make_log();
1365 let e0 = make_valid_entry(0, GENESIS_HASH);
1366 let hash0 = *e0.entry_hash();
1367 log.push(e0).unwrap();
1368
1369 let e1 = make_valid_entry(1, hash0);
1370 log.push(e1).unwrap();
1371
1372 assert_eq!(log.len(), 2);
1373 assert_eq!(log.entries()[0].seq(), 0);
1374 assert_eq!(log.entries()[1].seq(), 1);
1375 }
1376
1377 #[test]
1378 fn audit_log_error_display_sequence_gap() {
1379 let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
1380 let s = alloc::format!("{}", err);
1381 assert!(s.contains("expected seq=3"));
1382 assert!(s.contains("got seq=7"));
1383 }
1384
1385 #[test]
1386 fn audit_log_error_display_hash_chain_broken() {
1387 let err = AuditLogError::HashChainBroken { at_seq: 5 };
1388 let s = alloc::format!("{}", err);
1389 assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
1390 }
1391
1392 #[test]
1395 fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
1396 let mut log = make_log();
1397 let e = log.next_entry(
1398 AuditEventType::ToolCallIntercepted,
1399 1_000,
1400 alloc::string::String::from("{}"),
1401 );
1402 assert_eq!(e.seq(), 0);
1403 assert_eq!(e.previous_hash(), &GENESIS_HASH);
1404 assert!(e.verify_integrity());
1405 }
1406
1407 #[test]
1408 fn next_entry_auto_increments_seq() {
1409 let mut log = make_log();
1410 log.next_entry(
1411 AuditEventType::ToolCallIntercepted,
1412 1_000,
1413 alloc::string::String::from("{}"),
1414 );
1415 log.next_entry(
1416 AuditEventType::PolicyViolation,
1417 2_000,
1418 alloc::string::String::from("{}"),
1419 );
1420 log.next_entry(
1421 AuditEventType::ApprovalGranted,
1422 3_000,
1423 alloc::string::String::from("{}"),
1424 );
1425
1426 assert_eq!(log.len(), 3);
1427 assert_eq!(log.entries()[0].seq(), 0);
1428 assert_eq!(log.entries()[1].seq(), 1);
1429 assert_eq!(log.entries()[2].seq(), 2);
1430 }
1431
1432 #[test]
1433 fn next_entry_links_previous_hash_correctly() {
1434 let mut log = make_log();
1435 log.next_entry(
1436 AuditEventType::ToolCallIntercepted,
1437 1_000,
1438 alloc::string::String::from("{}"),
1439 );
1440 log.next_entry(
1441 AuditEventType::PolicyViolation,
1442 2_000,
1443 alloc::string::String::from("{}"),
1444 );
1445
1446 let e0_hash = *log.entries()[0].entry_hash();
1447 assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
1448 }
1449
1450 #[test]
1451 fn next_entry_mixed_with_push_works_correctly() {
1452 let mut log = make_log();
1453 log.next_entry(
1455 AuditEventType::ToolCallIntercepted,
1456 1_000,
1457 alloc::string::String::from("{}"),
1458 );
1459 let hash0 = *log.entries()[0].entry_hash();
1460
1461 let e1 = make_valid_entry(1, hash0);
1463 log.push(e1).unwrap();
1464
1465 log.next_entry(
1467 AuditEventType::ApprovalGranted,
1468 3_000,
1469 alloc::string::String::from("{}"),
1470 );
1471
1472 assert_eq!(log.len(), 3);
1473 assert_eq!(log.entries()[2].seq(), 2);
1474 assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
1475 }
1476
1477 #[test]
1478 fn next_entry_all_entries_pass_verify_integrity() {
1479 let mut log = make_log();
1480 for i in 0..5 {
1481 log.next_entry(
1482 AuditEventType::ToolCallIntercepted,
1483 i * 1_000,
1484 alloc::string::String::from("{}"),
1485 );
1486 }
1487 for entry in log.entries() {
1488 assert!(entry.verify_integrity());
1489 }
1490 }
1491
1492 #[test]
1495 fn verify_chain_empty_log_returns_true() {
1496 assert!(make_log().verify_chain());
1497 }
1498
1499 #[test]
1500 fn verify_chain_valid_log_returns_true() {
1501 let mut log = make_log();
1502 for i in 0..4 {
1503 log.next_entry(
1504 AuditEventType::ToolCallIntercepted,
1505 i * 1_000,
1506 alloc::string::String::from("{}"),
1507 );
1508 }
1509 assert!(log.verify_chain());
1510 }
1511
1512 #[test]
1513 fn verify_chain_false_after_unsafe_seq_tamper() {
1514 let mut log = make_log();
1515 log.next_entry(
1516 AuditEventType::ToolCallIntercepted,
1517 1_000,
1518 alloc::string::String::from("{}"),
1519 );
1520 log.next_entry(
1521 AuditEventType::PolicyViolation,
1522 2_000,
1523 alloc::string::String::from("{}"),
1524 );
1525
1526 unsafe {
1529 let entry = &mut *(log.entries.as_mut_ptr());
1530 let ptr = &mut entry.seq as *mut u64;
1531 *ptr = 99;
1532 }
1533 assert!(!log.verify_chain());
1534 }
1535
1536 #[test]
1537 fn verify_chain_false_after_unsafe_payload_tamper() {
1538 let mut log = make_log();
1539 log.next_entry(
1540 AuditEventType::ToolCallIntercepted,
1541 1_000,
1542 alloc::string::String::from("{}"),
1543 );
1544 log.next_entry(
1545 AuditEventType::PolicyViolation,
1546 2_000,
1547 alloc::string::String::from("{}"),
1548 );
1549
1550 unsafe {
1553 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1554 if let Some(b) = entry.payload.as_mut_vec().first_mut() {
1555 *b = b'X';
1556 }
1557 }
1558 assert!(!log.verify_chain());
1559 }
1560
1561 #[test]
1562 fn verify_chain_false_after_unsafe_previous_hash_tamper() {
1563 let mut log = make_log();
1564 log.next_entry(
1565 AuditEventType::ToolCallIntercepted,
1566 1_000,
1567 alloc::string::String::from("{}"),
1568 );
1569 log.next_entry(
1570 AuditEventType::PolicyViolation,
1571 2_000,
1572 alloc::string::String::from("{}"),
1573 );
1574
1575 unsafe {
1578 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1579 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1580 (*ptr)[0] = 0xFF;
1581 }
1582 assert!(!log.verify_chain());
1583 }
1584
1585 #[test]
1588 fn tool_dispatch_helper_emits_placeholder_form_payload() {
1589 let real_secret = "real-secret-abc-DEADBEEF-0001";
1591 let placeholder_args = serde_json::json!({
1592 "connection_string": "${DB_PASSWORD}"
1593 });
1594
1595 let entry = audit_entry_for_tool_dispatch(
1596 42,
1597 1_714_222_134_000_000_000,
1598 AgentId::from_bytes(AGENT_BYTES),
1599 SessionId::from_bytes(SESSION_BYTES),
1600 &placeholder_args,
1601 GENESIS_HASH,
1602 );
1603
1604 assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
1605 assert!(entry.payload().contains("${DB_PASSWORD}"));
1607 assert!(
1608 !entry.payload().contains(real_secret),
1609 "audit payload MUST NOT contain the resolved credential — placeholder-form contract"
1610 );
1611 }
1612}
1613
1614#[cfg(all(test, feature = "alloc", feature = "serde"))]
1615mod lineage_tests {
1616 use super::*;
1617
1618 const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
1619 const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
1620 const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
1621 const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);
1622
1623 fn base_entry() -> AuditEntry {
1624 AuditEntry::new(
1625 0,
1626 1_700_000_000_000_000_000,
1627 AuditEventType::ToolCallIntercepted,
1628 AGENT,
1629 SESSION,
1630 r#"{"tool":"bash"}"#.into(),
1631 [0u8; 32],
1632 )
1633 }
1634
1635 #[test]
1636 fn lineage_default_is_all_none() {
1637 let l = Lineage::default();
1638 assert!(l.root_agent_id.is_none());
1639 assert!(l.parent_agent_id.is_none());
1640 assert!(l.team_id.is_none());
1641 assert!(l.delegation_reason.is_none());
1642 assert!(l.spawned_by_tool.is_none());
1643 assert!(l.depth.is_none());
1644 }
1645
1646 #[test]
1647 fn new_with_empty_lineage_produces_same_hash_as_new() {
1648 let legacy = base_entry();
1649 let with_lineage = AuditEntry::new_with_lineage(
1650 0,
1651 1_700_000_000_000_000_000,
1652 AuditEventType::ToolCallIntercepted,
1653 AGENT,
1654 SESSION,
1655 r#"{"tool":"bash"}"#.into(),
1656 [0u8; 32],
1657 Lineage::default(),
1658 );
1659 assert_eq!(
1660 legacy.entry_hash(),
1661 with_lineage.entry_hash(),
1662 "Lineage::default() must not change the hash"
1663 );
1664 }
1665
1666 #[test]
1667 fn new_with_lineage_getters_return_correct_values() {
1668 let lineage = Lineage {
1669 root_agent_id: Some(ROOT),
1670 parent_agent_id: Some(PARENT),
1671 team_id: Some("team-alpha".into()),
1672 org_id: None,
1673 delegation_reason: Some("summarise".into()),
1674 spawned_by_tool: Some("langgraph".into()),
1675 depth: Some(2),
1676 };
1677 let entry = AuditEntry::new_with_lineage(
1678 0,
1679 1_000,
1680 AuditEventType::PolicyViolation,
1681 AGENT,
1682 SESSION,
1683 "{}".into(),
1684 [0u8; 32],
1685 lineage,
1686 );
1687 assert_eq!(entry.root_agent_id(), Some(ROOT));
1688 assert_eq!(entry.parent_agent_id(), Some(PARENT));
1689 assert_eq!(entry.team_id(), Some("team-alpha"));
1690 assert_eq!(entry.delegation_reason(), Some("summarise"));
1691 assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
1692 assert_eq!(entry.depth(), Some(2));
1693 }
1694
1695 #[test]
1696 fn verify_integrity_true_with_lineage() {
1697 let lineage = Lineage {
1698 root_agent_id: Some(ROOT),
1699 team_id: Some("ops".into()),
1700 depth: Some(1),
1701 ..Lineage::default()
1702 };
1703 let entry = AuditEntry::new_with_lineage(
1704 0,
1705 1_000,
1706 AuditEventType::ToolCallIntercepted,
1707 AGENT,
1708 SESSION,
1709 "{}".into(),
1710 [0u8; 32],
1711 lineage,
1712 );
1713 assert!(entry.verify_integrity());
1714 }
1715
1716 #[test]
1717 fn lineage_fields_change_hash() {
1718 let no_lineage = base_entry();
1719 let lineage = Lineage {
1720 depth: Some(1),
1721 ..Lineage::default()
1722 };
1723 let with_depth = AuditEntry::new_with_lineage(
1724 0,
1725 1_700_000_000_000_000_000,
1726 AuditEventType::ToolCallIntercepted,
1727 AGENT,
1728 SESSION,
1729 r#"{"tool":"bash"}"#.into(),
1730 [0u8; 32],
1731 lineage,
1732 );
1733 assert_ne!(
1734 no_lineage.entry_hash(),
1735 with_depth.entry_hash(),
1736 "A present lineage field must change the hash"
1737 );
1738 }
1739
1740 #[test]
1741 fn serde_round_trip_with_lineage() {
1742 let lineage = Lineage {
1743 root_agent_id: Some(ROOT),
1744 parent_agent_id: Some(PARENT),
1745 team_id: Some("t1".into()),
1746 org_id: Some("o1".into()),
1747 delegation_reason: Some("r".into()),
1748 spawned_by_tool: Some("s".into()),
1749 depth: Some(3),
1750 };
1751 let entry = AuditEntry::new_with_lineage(
1752 0,
1753 1_000,
1754 AuditEventType::ToolCallIntercepted,
1755 AGENT,
1756 SESSION,
1757 "{}".into(),
1758 [0u8; 32],
1759 lineage,
1760 );
1761 let json = serde_json::to_string(&entry).unwrap();
1762 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1763 assert_eq!(entry.entry_hash(), restored.entry_hash());
1764 assert_eq!(restored.root_agent_id(), Some(ROOT));
1765 assert_eq!(restored.depth(), Some(3));
1766 }
1767
1768 #[test]
1769 fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
1770 let pre_change_entry = AuditEntry::new(
1771 0,
1772 1_700_000_000_000_000_000,
1773 AuditEventType::ToolCallIntercepted,
1774 AGENT,
1775 SESSION,
1776 r#"{"tool":"bash"}"#.into(),
1777 [0u8; 32],
1778 );
1779 let json = serde_json::to_string(&pre_change_entry).unwrap();
1780 assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
1781 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1782 assert!(restored.root_agent_id().is_none());
1783 assert!(
1784 restored.verify_integrity(),
1785 "Legacy entries must still verify after adding lineage fields"
1786 );
1787 }
1788
1789 #[test]
1790 fn next_entry_with_lineage_links_chain() {
1791 let mut log = AuditLog::new(AGENT, SESSION);
1792 let lineage = Lineage {
1793 depth: Some(1),
1794 team_id: Some("t".into()),
1795 ..Lineage::default()
1796 };
1797 log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
1798 log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
1799 assert!(log.verify_chain());
1800 assert_eq!(log.len(), 2);
1801 }
1802}
1803
1804#[cfg(all(test, feature = "std", feature = "serde"))]
1805mod redaction_tests {
1806 use super::*;
1807 use aa_security::CredentialScanner;
1808
1809 const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
1810 const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);
1811
1812 const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
1814
1815 fn build_redaction_for_fake_secret() -> Redaction {
1816 let scanner = CredentialScanner::new();
1817 let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
1818 assert!(
1819 !scan.findings.is_empty(),
1820 "scanner must detect the synthetic AWS access key — fixture invariant",
1821 );
1822 let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
1823 Redaction {
1824 credential_findings: scan.findings,
1825 redacted_payload: Some(redacted),
1826 }
1827 }
1828
1829 #[test]
1830 fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
1831 let redaction = build_redaction_for_fake_secret();
1832 let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
1834 let entry = AuditEntry::new_with_lineage_and_redaction(
1835 0,
1836 1_700_000_000_000_000_000,
1837 AuditEventType::CredentialLeakBlocked,
1838 AGENT,
1839 SESSION,
1840 payload,
1841 [0u8; 32],
1842 Lineage::default(),
1843 redaction,
1844 );
1845
1846 let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");
1847
1848 assert!(
1852 !serialized.contains(FAKE_AWS_ACCESS_KEY),
1853 "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
1854 );
1855
1856 assert!(
1858 serialized.contains("[REDACTED:AwsAccessKey]"),
1859 "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
1860 );
1861
1862 assert!(
1864 entry.verify_integrity(),
1865 "verify_integrity must pass on a freshly constructed redacted entry",
1866 );
1867 }
1868
1869 #[test]
1870 fn redaction_default_preserves_legacy_hash() {
1871 let payload = String::from(r#"{"tool":"bash"}"#);
1875 let legacy = AuditEntry::new(
1876 0,
1877 1_700_000_000_000_000_000,
1878 AuditEventType::ToolCallIntercepted,
1879 AGENT,
1880 SESSION,
1881 payload.clone(),
1882 [0u8; 32],
1883 );
1884 let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
1885 0,
1886 1_700_000_000_000_000_000,
1887 AuditEventType::ToolCallIntercepted,
1888 AGENT,
1889 SESSION,
1890 payload,
1891 [0u8; 32],
1892 Lineage::default(),
1893 Redaction::default(),
1894 );
1895 assert_eq!(
1896 legacy.entry_hash(),
1897 with_default_redaction.entry_hash(),
1898 "Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
1899 );
1900 }
1901}