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}
116
117impl AuditEventType {
118 pub fn as_str(&self) -> &'static str {
122 match self {
123 Self::ToolCallIntercepted => "ToolCallIntercepted",
124 Self::PolicyViolation => "PolicyViolation",
125 Self::CredentialLeakBlocked => "CredentialLeakBlocked",
126 Self::ApprovalRequested => "ApprovalRequested",
127 Self::ApprovalGranted => "ApprovalGranted",
128 Self::ApprovalDenied => "ApprovalDenied",
129 Self::BudgetLimitApproached => "BudgetLimitApproached",
130 Self::BudgetLimitExceeded => "BudgetLimitExceeded",
131 Self::ApprovalTimedOut => "ApprovalTimedOut",
132 Self::ApprovalRouted => "ApprovalRouted",
133 Self::ApprovalEscalated => "ApprovalEscalated",
134 Self::AgentForceDeregistered => "AgentForceDeregistered",
135 Self::MessageBlocked => "MessageBlocked",
136 Self::ToolDispatched => "ToolDispatched",
137 Self::A2ACallIntercepted => "A2ACallIntercepted",
138 Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
139 Self::SandboxStarted => "SandboxStarted",
140 Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
141 Self::SandboxCpuTimeout => "SandboxCpuTimeout",
142 Self::SandboxOomKilled => "SandboxOomKilled",
143 Self::SandboxTerminated => "SandboxTerminated",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Default)]
159#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
160pub struct Lineage {
161 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
163 pub root_agent_id: Option<AgentId>,
164 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
166 pub parent_agent_id: Option<AgentId>,
167 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
169 pub team_id: Option<String>,
170 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
177 pub org_id: Option<String>,
178 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
180 pub delegation_reason: Option<String>,
181 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
183 pub spawned_by_tool: Option<String>,
184 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
186 pub depth: Option<u32>,
187}
188
189#[cfg(feature = "std")]
199use aa_security::Redaction;
200
201#[derive(Debug, Clone, PartialEq, Eq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct AuditEntry {
227 seq: u64,
228 timestamp_ns: u64,
229 event_type: AuditEventType,
230 agent_id: AgentId,
231 session_id: SessionId,
232 payload: String,
233 previous_hash: [u8; 32],
234 entry_hash: [u8; 32],
235 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
236 root_agent_id: Option<AgentId>,
237 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
238 parent_agent_id: Option<AgentId>,
239 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
240 team_id: Option<String>,
241 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
243 org_id: Option<String>,
244 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
245 delegation_reason: Option<String>,
246 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
247 spawned_by_tool: Option<String>,
248 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
249 depth: Option<u32>,
250 #[cfg(feature = "std")]
251 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
252 credential_findings: alloc::vec::Vec<aa_security::CredentialFinding>,
253 #[cfg(feature = "std")]
254 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
255 redacted_payload: Option<String>,
256}
257
258impl AuditEntry {
259 pub fn new(
291 seq: u64,
292 timestamp_ns: u64,
293 event_type: AuditEventType,
294 agent_id: AgentId,
295 session_id: SessionId,
296 payload: String,
297 previous_hash: [u8; 32],
298 ) -> Self {
299 let entry_hash = Self::compute_hash(
300 seq,
301 timestamp_ns,
302 &event_type,
303 &agent_id,
304 &session_id,
305 &previous_hash,
306 &payload,
307 &Lineage::default(),
308 #[cfg(feature = "std")]
309 &Redaction::default(),
310 );
311 Self {
312 seq,
313 timestamp_ns,
314 event_type,
315 agent_id,
316 session_id,
317 payload,
318 previous_hash,
319 entry_hash,
320 root_agent_id: None,
321 parent_agent_id: None,
322 team_id: None,
323 org_id: None,
324 delegation_reason: None,
325 spawned_by_tool: None,
326 depth: None,
327 #[cfg(feature = "std")]
328 credential_findings: alloc::vec::Vec::new(),
329 #[cfg(feature = "std")]
330 redacted_payload: None,
331 }
332 }
333
334 #[allow(clippy::too_many_arguments)]
341 pub fn new_with_lineage(
342 seq: u64,
343 timestamp_ns: u64,
344 event_type: AuditEventType,
345 agent_id: AgentId,
346 session_id: SessionId,
347 payload: String,
348 previous_hash: [u8; 32],
349 lineage: Lineage,
350 ) -> Self {
351 let entry_hash = Self::compute_hash(
352 seq,
353 timestamp_ns,
354 &event_type,
355 &agent_id,
356 &session_id,
357 &previous_hash,
358 &payload,
359 &lineage,
360 #[cfg(feature = "std")]
361 &Redaction::default(),
362 );
363 Self {
364 seq,
365 timestamp_ns,
366 event_type,
367 agent_id,
368 session_id,
369 payload,
370 previous_hash,
371 entry_hash,
372 root_agent_id: lineage.root_agent_id,
373 parent_agent_id: lineage.parent_agent_id,
374 team_id: lineage.team_id,
375 org_id: lineage.org_id,
376 delegation_reason: lineage.delegation_reason,
377 spawned_by_tool: lineage.spawned_by_tool,
378 depth: lineage.depth,
379 #[cfg(feature = "std")]
380 credential_findings: alloc::vec::Vec::new(),
381 #[cfg(feature = "std")]
382 redacted_payload: None,
383 }
384 }
385
386 #[cfg(feature = "std")]
398 #[allow(clippy::too_many_arguments)]
399 pub fn new_with_lineage_and_redaction(
400 seq: u64,
401 timestamp_ns: u64,
402 event_type: AuditEventType,
403 agent_id: AgentId,
404 session_id: SessionId,
405 payload: String,
406 previous_hash: [u8; 32],
407 lineage: Lineage,
408 redaction: Redaction,
409 ) -> Self {
410 let entry_hash = Self::compute_hash(
411 seq,
412 timestamp_ns,
413 &event_type,
414 &agent_id,
415 &session_id,
416 &previous_hash,
417 &payload,
418 &lineage,
419 &redaction,
420 );
421 Self {
422 seq,
423 timestamp_ns,
424 event_type,
425 agent_id,
426 session_id,
427 payload,
428 previous_hash,
429 entry_hash,
430 root_agent_id: lineage.root_agent_id,
431 parent_agent_id: lineage.parent_agent_id,
432 team_id: lineage.team_id,
433 org_id: lineage.org_id,
434 delegation_reason: lineage.delegation_reason,
435 spawned_by_tool: lineage.spawned_by_tool,
436 depth: lineage.depth,
437 credential_findings: redaction.credential_findings,
438 redacted_payload: redaction.redacted_payload,
439 }
440 }
441
442 #[inline]
448 pub fn seq(&self) -> u64 {
449 self.seq
450 }
451
452 #[inline]
454 pub fn timestamp_ns(&self) -> u64 {
455 self.timestamp_ns
456 }
457
458 #[inline]
460 pub fn event_type(&self) -> AuditEventType {
461 self.event_type
462 }
463
464 #[inline]
466 pub fn agent_id(&self) -> AgentId {
467 self.agent_id
468 }
469
470 #[inline]
472 pub fn session_id(&self) -> SessionId {
473 self.session_id
474 }
475
476 #[inline]
478 pub fn payload(&self) -> &str {
479 &self.payload
480 }
481
482 #[inline]
484 pub fn previous_hash(&self) -> &[u8; 32] {
485 &self.previous_hash
486 }
487
488 #[inline]
490 pub fn entry_hash(&self) -> &[u8; 32] {
491 &self.entry_hash
492 }
493
494 #[inline]
496 pub fn root_agent_id(&self) -> Option<AgentId> {
497 self.root_agent_id
498 }
499
500 #[inline]
502 pub fn parent_agent_id(&self) -> Option<AgentId> {
503 self.parent_agent_id
504 }
505
506 #[inline]
508 pub fn team_id(&self) -> Option<&str> {
509 self.team_id.as_deref()
510 }
511
512 #[inline]
515 pub fn org_id(&self) -> Option<&str> {
516 self.org_id.as_deref()
517 }
518
519 #[inline]
521 pub fn delegation_reason(&self) -> Option<&str> {
522 self.delegation_reason.as_deref()
523 }
524
525 #[inline]
527 pub fn spawned_by_tool(&self) -> Option<&str> {
528 self.spawned_by_tool.as_deref()
529 }
530
531 #[inline]
533 pub fn depth(&self) -> Option<u32> {
534 self.depth
535 }
536
537 #[cfg(feature = "std")]
544 #[inline]
545 pub fn credential_findings(&self) -> &[aa_security::CredentialFinding] {
546 &self.credential_findings
547 }
548
549 #[cfg(feature = "std")]
555 #[inline]
556 pub fn redacted_payload(&self) -> Option<&str> {
557 self.redacted_payload.as_deref()
558 }
559
560 pub fn verify_integrity(&self) -> bool {
570 let lineage = Lineage {
571 root_agent_id: self.root_agent_id,
572 parent_agent_id: self.parent_agent_id,
573 team_id: self.team_id.clone(),
574 org_id: self.org_id.clone(),
575 delegation_reason: self.delegation_reason.clone(),
576 spawned_by_tool: self.spawned_by_tool.clone(),
577 depth: self.depth,
578 };
579 #[cfg(feature = "std")]
580 let redaction = Redaction {
581 credential_findings: self.credential_findings.clone(),
582 redacted_payload: self.redacted_payload.clone(),
583 };
584 let expected = Self::compute_hash(
585 self.seq,
586 self.timestamp_ns,
587 &self.event_type,
588 &self.agent_id,
589 &self.session_id,
590 &self.previous_hash,
591 &self.payload,
592 &lineage,
593 #[cfg(feature = "std")]
594 &redaction,
595 );
596 expected == self.entry_hash
597 }
598
599 #[allow(clippy::too_many_arguments)]
612 fn compute_hash(
613 seq: u64,
614 timestamp_ns: u64,
615 event_type: &AuditEventType,
616 agent_id: &AgentId,
617 session_id: &SessionId,
618 previous_hash: &[u8; 32],
619 payload: &str,
620 lineage: &Lineage,
621 #[cfg(feature = "std")] redaction: &Redaction,
622 ) -> [u8; 32] {
623 let mut hasher = Sha256::new();
624 hasher.update(seq.to_be_bytes());
625 hasher.update(timestamp_ns.to_be_bytes());
626 hasher.update((*event_type as u32).to_be_bytes());
627 hasher.update(agent_id.as_bytes());
628 hasher.update(session_id.as_bytes());
629 hasher.update(previous_hash);
630 hasher.update(payload.as_bytes());
631 if let Some(id) = &lineage.root_agent_id {
634 hasher.update(id.as_bytes());
635 }
636 if let Some(id) = &lineage.parent_agent_id {
637 hasher.update(id.as_bytes());
638 }
639 if let Some(s) = &lineage.team_id {
640 hasher.update((s.len() as u32).to_be_bytes());
641 hasher.update(s.as_bytes());
642 }
643 if let Some(s) = &lineage.delegation_reason {
644 hasher.update((s.len() as u32).to_be_bytes());
645 hasher.update(s.as_bytes());
646 }
647 if let Some(s) = &lineage.spawned_by_tool {
648 hasher.update((s.len() as u32).to_be_bytes());
649 hasher.update(s.as_bytes());
650 }
651 if let Some(d) = lineage.depth {
652 hasher.update(d.to_be_bytes());
653 }
654 if let Some(s) = &lineage.org_id {
657 hasher.update((s.len() as u32).to_be_bytes());
658 hasher.update(s.as_bytes());
659 }
660 #[cfg(feature = "std")]
663 {
664 if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
665 hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
666 for finding in &redaction.credential_findings {
667 hasher.update((finding.offset as u64).to_be_bytes());
668 hasher.update((finding.matched.len() as u32).to_be_bytes());
669 hasher.update(finding.matched.as_bytes());
670 }
671 if let Some(s) = &redaction.redacted_payload {
672 hasher.update([1u8]);
673 hasher.update((s.len() as u32).to_be_bytes());
674 hasher.update(s.as_bytes());
675 } else {
676 hasher.update([0u8]);
677 }
678 }
679 }
680 hasher.finalize().into()
681 }
682}
683
684#[cfg(feature = "std")]
702pub fn audit_entry_for_tool_dispatch(
703 seq: u64,
704 timestamp_ns: u64,
705 agent_id: AgentId,
706 session_id: SessionId,
707 placeholder_args: &serde_json::Value,
708 previous_hash: [u8; 32],
709) -> AuditEntry {
710 let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
711 String::from("{\"error\":\"failed to serialize placeholder args\"}")
715 });
716 AuditEntry::new(
717 seq,
718 timestamp_ns,
719 AuditEventType::ToolDispatched,
720 agent_id,
721 session_id,
722 payload,
723 previous_hash,
724 )
725}
726
727impl core::fmt::Display for AuditEntry {
732 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
739 write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
740 for b in self.agent_id.as_bytes() {
741 write!(f, "{:02x}", b)?;
742 }
743 write!(f, " session=")?;
744 for b in self.session_id.as_bytes() {
745 write!(f, "{:02x}", b)?;
746 }
747 write!(f, " event={}]", self.event_type.as_str())
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
758pub enum AuditLogError {
759 SequenceGap {
761 expected: u64,
763 got: u64,
765 },
766 HashChainBroken {
769 at_seq: u64,
771 },
772}
773
774impl core::fmt::Display for AuditLogError {
775 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
776 match self {
777 Self::SequenceGap { expected, got } => {
778 write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
779 }
780 Self::HashChainBroken { at_seq } => {
781 write!(f, "audit log hash chain broken at seq={at_seq}")
782 }
783 }
784 }
785}
786
787pub struct AuditLog {
803 agent_id: AgentId,
804 session_id: SessionId,
805 entries: alloc::vec::Vec<AuditEntry>,
806 next_seq: u64,
808 last_hash: [u8; 32],
810}
811
812impl AuditLog {
813 pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
818 Self {
819 agent_id,
820 session_id,
821 entries: alloc::vec::Vec::new(),
822 next_seq: 0,
823 last_hash: [0u8; 32],
824 }
825 }
826
827 pub fn entries(&self) -> &[AuditEntry] {
829 &self.entries
830 }
831
832 pub fn len(&self) -> usize {
834 self.entries.len()
835 }
836
837 pub fn is_empty(&self) -> bool {
839 self.entries.is_empty()
840 }
841
842 pub fn agent_id(&self) -> AgentId {
844 self.agent_id
845 }
846
847 pub fn session_id(&self) -> SessionId {
849 self.session_id
850 }
851
852 pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
861 if entry.seq() != self.next_seq {
862 return Err(AuditLogError::SequenceGap {
863 expected: self.next_seq,
864 got: entry.seq(),
865 });
866 }
867 if entry.previous_hash() != &self.last_hash {
868 return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
869 }
870 self.last_hash = *entry.entry_hash();
871 self.next_seq += 1;
872 self.entries.push(entry);
873 Ok(())
874 }
875
876 pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
890 let entry = AuditEntry::new(
891 self.next_seq,
892 timestamp_ns,
893 event_type,
894 self.agent_id,
895 self.session_id,
896 payload,
897 self.last_hash,
898 );
899 self.push(entry).expect("next_entry invariant: push cannot fail");
902 self.entries.last().expect("entry was just pushed")
903 }
904
905 pub fn next_entry_with_lineage(
920 &mut self,
921 event_type: AuditEventType,
922 timestamp_ns: u64,
923 payload: String,
924 lineage: Lineage,
925 ) -> &AuditEntry {
926 let entry = AuditEntry::new_with_lineage(
927 self.next_seq,
928 timestamp_ns,
929 event_type,
930 self.agent_id,
931 self.session_id,
932 payload,
933 self.last_hash,
934 lineage,
935 );
936 self.push(entry)
937 .expect("next_entry_with_lineage invariant: push cannot fail");
938 self.entries.last().expect("entry was just pushed")
939 }
940
941 pub fn verify_chain(&self) -> bool {
952 let mut expected_prev_hash: [u8; 32] = [0u8; 32];
953
954 for (expected_seq, entry) in self.entries.iter().enumerate() {
955 if !entry.verify_integrity() {
956 return false;
957 }
958 if entry.seq() != expected_seq as u64 {
959 return false;
960 }
961 if entry.previous_hash() != &expected_prev_hash {
962 return false;
963 }
964 expected_prev_hash = *entry.entry_hash();
965 }
966 true
967 }
968}
969
970#[cfg(test)]
975mod tests {
976 use super::*;
977
978 const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
980 const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
981 const GENESIS_HASH: [u8; 32] = [0u8; 32];
982
983 fn make_entry(seq: u64) -> AuditEntry {
984 AuditEntry::new(
985 seq,
986 1_714_222_134_000_000_000,
987 AuditEventType::ToolCallIntercepted,
988 AgentId::from_bytes(AGENT_BYTES),
989 SessionId::from_bytes(SESSION_BYTES),
990 alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
991 GENESIS_HASH,
992 )
993 }
994
995 #[test]
998 fn event_type_as_str_all_variants() {
999 assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
1000 assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
1001 assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
1002 assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
1003 assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
1004 assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
1005 assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
1006 assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
1007 assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
1008 assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
1009 assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
1010 assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
1011 assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
1012 assert_eq!(
1013 AuditEventType::SandboxFilesystemBlocked.as_str(),
1014 "SandboxFilesystemBlocked"
1015 );
1016 assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
1017 assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
1018 assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
1019 }
1020
1021 #[test]
1022 fn event_type_discriminants_are_0_through_10() {
1023 assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
1024 assert_eq!(AuditEventType::PolicyViolation as u32, 1);
1025 assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
1026 assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
1027 assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
1028 assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
1029 assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
1030 assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
1031 assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
1032 assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
1033 assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
1034 assert_eq!(AuditEventType::ToolDispatched as u32, 13);
1035 assert_eq!(AuditEventType::SandboxStarted as u32, 16);
1036 assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
1037 assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
1038 assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
1039 assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
1040 }
1041
1042 #[test]
1043 fn event_type_variants_are_all_distinct() {
1044 let variants = [
1045 AuditEventType::ToolCallIntercepted,
1046 AuditEventType::PolicyViolation,
1047 AuditEventType::CredentialLeakBlocked,
1048 AuditEventType::ApprovalRequested,
1049 AuditEventType::ApprovalGranted,
1050 AuditEventType::ApprovalDenied,
1051 AuditEventType::BudgetLimitApproached,
1052 AuditEventType::BudgetLimitExceeded,
1053 AuditEventType::ApprovalTimedOut,
1054 AuditEventType::ApprovalRouted,
1055 AuditEventType::ApprovalEscalated,
1056 AuditEventType::ToolDispatched,
1057 AuditEventType::SandboxStarted,
1058 AuditEventType::SandboxFilesystemBlocked,
1059 AuditEventType::SandboxCpuTimeout,
1060 AuditEventType::SandboxOomKilled,
1061 AuditEventType::SandboxTerminated,
1062 ];
1063 for i in 0..variants.len() {
1064 for j in (i + 1)..variants.len() {
1065 assert_ne!(variants[i], variants[j]);
1066 }
1067 }
1068 }
1069
1070 #[test]
1073 fn new_produces_nonzero_entry_hash() {
1074 let entry = make_entry(0);
1075 assert_ne!(entry.entry_hash(), &[0u8; 32]);
1076 }
1077
1078 #[test]
1079 fn getters_return_correct_values() {
1080 let payload = alloc::string::String::from("{\"k\":\"v\"}");
1081 let entry = AuditEntry::new(
1082 42,
1083 999_000_000,
1084 AuditEventType::PolicyViolation,
1085 AgentId::from_bytes(AGENT_BYTES),
1086 SessionId::from_bytes(SESSION_BYTES),
1087 payload.clone(),
1088 GENESIS_HASH,
1089 );
1090 assert_eq!(entry.seq(), 42);
1091 assert_eq!(entry.timestamp_ns(), 999_000_000);
1092 assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
1093 assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
1094 assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
1095 assert_eq!(entry.payload(), "{\"k\":\"v\"}");
1096 assert_eq!(entry.previous_hash(), &GENESIS_HASH);
1097 }
1098
1099 #[test]
1100 fn genesis_entry_uses_zero_previous_hash() {
1101 let entry = make_entry(0);
1102 assert_eq!(entry.previous_hash(), &[0u8; 32]);
1103 }
1104
1105 #[test]
1108 fn verify_integrity_true_for_untampered_entry() {
1109 assert!(make_entry(0).verify_integrity());
1110 }
1111
1112 #[test]
1113 fn verify_integrity_false_after_seq_tamper() {
1114 let mut entry = make_entry(0);
1115 unsafe {
1117 let ptr = &mut entry.seq as *mut u64;
1118 *ptr = 999;
1119 }
1120 assert!(!entry.verify_integrity());
1121 }
1122
1123 #[test]
1124 fn verify_integrity_false_after_payload_tamper() {
1125 let mut entry = make_entry(0);
1126 unsafe {
1128 let ptr = entry.payload.as_mut_vec();
1129 if let Some(b) = ptr.first_mut() {
1130 *b = b'X';
1131 }
1132 }
1133 assert!(!entry.verify_integrity());
1134 }
1135
1136 #[test]
1137 fn verify_integrity_false_after_event_type_tamper() {
1138 let mut entry = make_entry(0);
1139 unsafe {
1141 let ptr = &mut entry.event_type as *mut AuditEventType;
1142 *ptr = AuditEventType::BudgetLimitExceeded;
1143 }
1144 assert!(!entry.verify_integrity());
1145 }
1146
1147 #[test]
1148 fn verify_integrity_false_after_previous_hash_tamper() {
1149 let mut entry = make_entry(0);
1150 unsafe {
1152 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1153 (*ptr)[0] = 0xFF;
1154 }
1155 assert!(!entry.verify_integrity());
1156 }
1157
1158 #[test]
1161 fn chained_entries_have_distinct_hashes() {
1162 let first = make_entry(0);
1163 let second = AuditEntry::new(
1164 1,
1165 1_714_222_134_000_000_001,
1166 AuditEventType::PolicyViolation,
1167 AgentId::from_bytes(AGENT_BYTES),
1168 SessionId::from_bytes(SESSION_BYTES),
1169 alloc::string::String::from("{\"rule\":\"deny\"}"),
1170 *first.entry_hash(),
1171 );
1172 assert_ne!(first.entry_hash(), second.entry_hash());
1173 assert_eq!(second.previous_hash(), first.entry_hash());
1174 assert!(second.verify_integrity());
1175 }
1176
1177 #[test]
1178 fn different_seq_produces_different_hash() {
1179 let a = make_entry(0);
1180 let b = make_entry(1);
1181 assert_ne!(a.entry_hash(), b.entry_hash());
1182 }
1183
1184 #[test]
1185 fn different_previous_hash_produces_different_entry_hash() {
1186 let prev_a = [0u8; 32];
1187 let mut prev_b = [0u8; 32];
1188 prev_b[0] = 1;
1189
1190 let a = AuditEntry::new(
1191 0,
1192 0,
1193 AuditEventType::ToolCallIntercepted,
1194 AgentId::from_bytes(AGENT_BYTES),
1195 SessionId::from_bytes(SESSION_BYTES),
1196 alloc::string::String::from("{}"),
1197 prev_a,
1198 );
1199 let b = AuditEntry::new(
1200 0,
1201 0,
1202 AuditEventType::ToolCallIntercepted,
1203 AgentId::from_bytes(AGENT_BYTES),
1204 SessionId::from_bytes(SESSION_BYTES),
1205 alloc::string::String::from("{}"),
1206 prev_b,
1207 );
1208 assert_ne!(a.entry_hash(), b.entry_hash());
1209 }
1210
1211 #[test]
1214 fn display_contains_seq_ts_and_event_name() {
1215 let entry = make_entry(7);
1216 let s = alloc::format!("{}", entry);
1217 assert!(s.starts_with('['));
1218 assert!(s.ends_with(']'));
1219 assert!(s.contains("seq=7"));
1220 assert!(s.contains("ts=1714222134000000000"));
1221 assert!(s.contains("event=ToolCallIntercepted"));
1222 }
1223
1224 #[test]
1225 fn display_contains_agent_and_session_hex() {
1226 let entry = make_entry(0);
1227 let s = alloc::format!("{}", entry);
1228 assert!(s.contains("agent=01020304"));
1230 assert!(s.contains("session=11121314"));
1232 }
1233
1234 #[test]
1235 fn display_does_not_contain_payload() {
1236 let entry = make_entry(0);
1237 let s = alloc::format!("{}", entry);
1238 assert!(!s.contains("bash"));
1239 }
1240
1241 #[test]
1242 fn display_round_trips_sandbox_event_names() {
1243 let sandbox_events = [
1247 (AuditEventType::SandboxStarted, "event=SandboxStarted]"),
1248 (
1249 AuditEventType::SandboxFilesystemBlocked,
1250 "event=SandboxFilesystemBlocked]",
1251 ),
1252 (AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
1253 (AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
1254 (AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
1255 ];
1256 for (event_type, expected_tail) in sandbox_events {
1257 let entry = AuditEntry::new(
1258 0,
1259 1_714_222_134_000_000_000,
1260 event_type,
1261 AgentId::from_bytes(AGENT_BYTES),
1262 SessionId::from_bytes(SESSION_BYTES),
1263 alloc::string::String::from("{}"),
1264 GENESIS_HASH,
1265 );
1266 let rendered = alloc::format!("{}", entry);
1267 assert!(
1268 rendered.ends_with(expected_tail),
1269 "Display for {:?} should end with `{}` but was `{}`",
1270 event_type,
1271 expected_tail,
1272 rendered,
1273 );
1274 }
1275 }
1276
1277 fn make_log() -> AuditLog {
1280 AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
1281 }
1282
1283 fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
1284 AuditEntry::new(
1285 seq,
1286 1_000_000_000,
1287 AuditEventType::ToolCallIntercepted,
1288 AgentId::from_bytes(AGENT_BYTES),
1289 SessionId::from_bytes(SESSION_BYTES),
1290 alloc::string::String::from("{}"),
1291 previous_hash,
1292 )
1293 }
1294
1295 #[test]
1298 fn push_genesis_entry_succeeds() {
1299 let mut log = make_log();
1300 let entry = make_valid_entry(0, GENESIS_HASH);
1301 assert!(log.push(entry).is_ok());
1302 assert_eq!(log.len(), 1);
1303 }
1304
1305 #[test]
1306 fn push_rejects_seq_gap_skipping_forward() {
1307 let mut log = make_log();
1308 let entry = make_valid_entry(2, GENESIS_HASH); let err = log.push(entry).unwrap_err();
1310 assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
1311 assert!(log.is_empty(), "log must be unmodified on error");
1312 }
1313
1314 #[test]
1315 fn push_rejects_seq_going_backward() {
1316 let mut log = make_log();
1317 let e0 = make_valid_entry(0, GENESIS_HASH);
1318 let hash0 = *e0.entry_hash();
1319 log.push(e0).unwrap();
1320
1321 let e_back = make_valid_entry(0, hash0); let err = log.push(e_back).unwrap_err();
1323 assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
1324 assert_eq!(log.len(), 1, "log must be unmodified on error");
1325 }
1326
1327 #[test]
1328 fn push_rejects_broken_hash_chain() {
1329 let mut log = make_log();
1330 let e0 = make_valid_entry(0, GENESIS_HASH);
1331 log.push(e0).unwrap();
1332
1333 let wrong_prev = [0xAB; 32]; let e1 = make_valid_entry(1, wrong_prev);
1335 let err = log.push(e1).unwrap_err();
1336 assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
1337 assert_eq!(log.len(), 1, "log must be unmodified on error");
1338 }
1339
1340 #[test]
1341 fn push_two_valid_entries_succeeds() {
1342 let mut log = make_log();
1343 let e0 = make_valid_entry(0, GENESIS_HASH);
1344 let hash0 = *e0.entry_hash();
1345 log.push(e0).unwrap();
1346
1347 let e1 = make_valid_entry(1, hash0);
1348 log.push(e1).unwrap();
1349
1350 assert_eq!(log.len(), 2);
1351 assert_eq!(log.entries()[0].seq(), 0);
1352 assert_eq!(log.entries()[1].seq(), 1);
1353 }
1354
1355 #[test]
1356 fn audit_log_error_display_sequence_gap() {
1357 let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
1358 let s = alloc::format!("{}", err);
1359 assert!(s.contains("expected seq=3"));
1360 assert!(s.contains("got seq=7"));
1361 }
1362
1363 #[test]
1364 fn audit_log_error_display_hash_chain_broken() {
1365 let err = AuditLogError::HashChainBroken { at_seq: 5 };
1366 let s = alloc::format!("{}", err);
1367 assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
1368 }
1369
1370 #[test]
1373 fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
1374 let mut log = make_log();
1375 let e = log.next_entry(
1376 AuditEventType::ToolCallIntercepted,
1377 1_000,
1378 alloc::string::String::from("{}"),
1379 );
1380 assert_eq!(e.seq(), 0);
1381 assert_eq!(e.previous_hash(), &GENESIS_HASH);
1382 assert!(e.verify_integrity());
1383 }
1384
1385 #[test]
1386 fn next_entry_auto_increments_seq() {
1387 let mut log = make_log();
1388 log.next_entry(
1389 AuditEventType::ToolCallIntercepted,
1390 1_000,
1391 alloc::string::String::from("{}"),
1392 );
1393 log.next_entry(
1394 AuditEventType::PolicyViolation,
1395 2_000,
1396 alloc::string::String::from("{}"),
1397 );
1398 log.next_entry(
1399 AuditEventType::ApprovalGranted,
1400 3_000,
1401 alloc::string::String::from("{}"),
1402 );
1403
1404 assert_eq!(log.len(), 3);
1405 assert_eq!(log.entries()[0].seq(), 0);
1406 assert_eq!(log.entries()[1].seq(), 1);
1407 assert_eq!(log.entries()[2].seq(), 2);
1408 }
1409
1410 #[test]
1411 fn next_entry_links_previous_hash_correctly() {
1412 let mut log = make_log();
1413 log.next_entry(
1414 AuditEventType::ToolCallIntercepted,
1415 1_000,
1416 alloc::string::String::from("{}"),
1417 );
1418 log.next_entry(
1419 AuditEventType::PolicyViolation,
1420 2_000,
1421 alloc::string::String::from("{}"),
1422 );
1423
1424 let e0_hash = *log.entries()[0].entry_hash();
1425 assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
1426 }
1427
1428 #[test]
1429 fn next_entry_mixed_with_push_works_correctly() {
1430 let mut log = make_log();
1431 log.next_entry(
1433 AuditEventType::ToolCallIntercepted,
1434 1_000,
1435 alloc::string::String::from("{}"),
1436 );
1437 let hash0 = *log.entries()[0].entry_hash();
1438
1439 let e1 = make_valid_entry(1, hash0);
1441 log.push(e1).unwrap();
1442
1443 log.next_entry(
1445 AuditEventType::ApprovalGranted,
1446 3_000,
1447 alloc::string::String::from("{}"),
1448 );
1449
1450 assert_eq!(log.len(), 3);
1451 assert_eq!(log.entries()[2].seq(), 2);
1452 assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
1453 }
1454
1455 #[test]
1456 fn next_entry_all_entries_pass_verify_integrity() {
1457 let mut log = make_log();
1458 for i in 0..5 {
1459 log.next_entry(
1460 AuditEventType::ToolCallIntercepted,
1461 i * 1_000,
1462 alloc::string::String::from("{}"),
1463 );
1464 }
1465 for entry in log.entries() {
1466 assert!(entry.verify_integrity());
1467 }
1468 }
1469
1470 #[test]
1473 fn verify_chain_empty_log_returns_true() {
1474 assert!(make_log().verify_chain());
1475 }
1476
1477 #[test]
1478 fn verify_chain_valid_log_returns_true() {
1479 let mut log = make_log();
1480 for i in 0..4 {
1481 log.next_entry(
1482 AuditEventType::ToolCallIntercepted,
1483 i * 1_000,
1484 alloc::string::String::from("{}"),
1485 );
1486 }
1487 assert!(log.verify_chain());
1488 }
1489
1490 #[test]
1491 fn verify_chain_false_after_unsafe_seq_tamper() {
1492 let mut log = make_log();
1493 log.next_entry(
1494 AuditEventType::ToolCallIntercepted,
1495 1_000,
1496 alloc::string::String::from("{}"),
1497 );
1498 log.next_entry(
1499 AuditEventType::PolicyViolation,
1500 2_000,
1501 alloc::string::String::from("{}"),
1502 );
1503
1504 unsafe {
1507 let entry = &mut *(log.entries.as_mut_ptr());
1508 let ptr = &mut entry.seq as *mut u64;
1509 *ptr = 99;
1510 }
1511 assert!(!log.verify_chain());
1512 }
1513
1514 #[test]
1515 fn verify_chain_false_after_unsafe_payload_tamper() {
1516 let mut log = make_log();
1517 log.next_entry(
1518 AuditEventType::ToolCallIntercepted,
1519 1_000,
1520 alloc::string::String::from("{}"),
1521 );
1522 log.next_entry(
1523 AuditEventType::PolicyViolation,
1524 2_000,
1525 alloc::string::String::from("{}"),
1526 );
1527
1528 unsafe {
1531 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1532 if let Some(b) = entry.payload.as_mut_vec().first_mut() {
1533 *b = b'X';
1534 }
1535 }
1536 assert!(!log.verify_chain());
1537 }
1538
1539 #[test]
1540 fn verify_chain_false_after_unsafe_previous_hash_tamper() {
1541 let mut log = make_log();
1542 log.next_entry(
1543 AuditEventType::ToolCallIntercepted,
1544 1_000,
1545 alloc::string::String::from("{}"),
1546 );
1547 log.next_entry(
1548 AuditEventType::PolicyViolation,
1549 2_000,
1550 alloc::string::String::from("{}"),
1551 );
1552
1553 unsafe {
1556 let entry = &mut *(log.entries.as_mut_ptr().add(1));
1557 let ptr = &mut entry.previous_hash as *mut [u8; 32];
1558 (*ptr)[0] = 0xFF;
1559 }
1560 assert!(!log.verify_chain());
1561 }
1562
1563 #[test]
1566 fn tool_dispatch_helper_emits_placeholder_form_payload() {
1567 let real_secret = "real-secret-abc-DEADBEEF-0001";
1569 let placeholder_args = serde_json::json!({
1570 "connection_string": "${DB_PASSWORD}"
1571 });
1572
1573 let entry = audit_entry_for_tool_dispatch(
1574 42,
1575 1_714_222_134_000_000_000,
1576 AgentId::from_bytes(AGENT_BYTES),
1577 SessionId::from_bytes(SESSION_BYTES),
1578 &placeholder_args,
1579 GENESIS_HASH,
1580 );
1581
1582 assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
1583 assert!(entry.payload().contains("${DB_PASSWORD}"));
1585 assert!(
1586 !entry.payload().contains(real_secret),
1587 "audit payload MUST NOT contain the resolved credential — placeholder-form contract"
1588 );
1589 }
1590}
1591
1592#[cfg(all(test, feature = "alloc", feature = "serde"))]
1593mod lineage_tests {
1594 use super::*;
1595
1596 const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
1597 const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
1598 const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
1599 const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);
1600
1601 fn base_entry() -> AuditEntry {
1602 AuditEntry::new(
1603 0,
1604 1_700_000_000_000_000_000,
1605 AuditEventType::ToolCallIntercepted,
1606 AGENT,
1607 SESSION,
1608 r#"{"tool":"bash"}"#.into(),
1609 [0u8; 32],
1610 )
1611 }
1612
1613 #[test]
1614 fn lineage_default_is_all_none() {
1615 let l = Lineage::default();
1616 assert!(l.root_agent_id.is_none());
1617 assert!(l.parent_agent_id.is_none());
1618 assert!(l.team_id.is_none());
1619 assert!(l.delegation_reason.is_none());
1620 assert!(l.spawned_by_tool.is_none());
1621 assert!(l.depth.is_none());
1622 }
1623
1624 #[test]
1625 fn new_with_empty_lineage_produces_same_hash_as_new() {
1626 let legacy = base_entry();
1627 let with_lineage = AuditEntry::new_with_lineage(
1628 0,
1629 1_700_000_000_000_000_000,
1630 AuditEventType::ToolCallIntercepted,
1631 AGENT,
1632 SESSION,
1633 r#"{"tool":"bash"}"#.into(),
1634 [0u8; 32],
1635 Lineage::default(),
1636 );
1637 assert_eq!(
1638 legacy.entry_hash(),
1639 with_lineage.entry_hash(),
1640 "Lineage::default() must not change the hash"
1641 );
1642 }
1643
1644 #[test]
1645 fn new_with_lineage_getters_return_correct_values() {
1646 let lineage = Lineage {
1647 root_agent_id: Some(ROOT),
1648 parent_agent_id: Some(PARENT),
1649 team_id: Some("team-alpha".into()),
1650 org_id: None,
1651 delegation_reason: Some("summarise".into()),
1652 spawned_by_tool: Some("langgraph".into()),
1653 depth: Some(2),
1654 };
1655 let entry = AuditEntry::new_with_lineage(
1656 0,
1657 1_000,
1658 AuditEventType::PolicyViolation,
1659 AGENT,
1660 SESSION,
1661 "{}".into(),
1662 [0u8; 32],
1663 lineage,
1664 );
1665 assert_eq!(entry.root_agent_id(), Some(ROOT));
1666 assert_eq!(entry.parent_agent_id(), Some(PARENT));
1667 assert_eq!(entry.team_id(), Some("team-alpha"));
1668 assert_eq!(entry.delegation_reason(), Some("summarise"));
1669 assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
1670 assert_eq!(entry.depth(), Some(2));
1671 }
1672
1673 #[test]
1674 fn verify_integrity_true_with_lineage() {
1675 let lineage = Lineage {
1676 root_agent_id: Some(ROOT),
1677 team_id: Some("ops".into()),
1678 depth: Some(1),
1679 ..Lineage::default()
1680 };
1681 let entry = AuditEntry::new_with_lineage(
1682 0,
1683 1_000,
1684 AuditEventType::ToolCallIntercepted,
1685 AGENT,
1686 SESSION,
1687 "{}".into(),
1688 [0u8; 32],
1689 lineage,
1690 );
1691 assert!(entry.verify_integrity());
1692 }
1693
1694 #[test]
1695 fn lineage_fields_change_hash() {
1696 let no_lineage = base_entry();
1697 let lineage = Lineage {
1698 depth: Some(1),
1699 ..Lineage::default()
1700 };
1701 let with_depth = AuditEntry::new_with_lineage(
1702 0,
1703 1_700_000_000_000_000_000,
1704 AuditEventType::ToolCallIntercepted,
1705 AGENT,
1706 SESSION,
1707 r#"{"tool":"bash"}"#.into(),
1708 [0u8; 32],
1709 lineage,
1710 );
1711 assert_ne!(
1712 no_lineage.entry_hash(),
1713 with_depth.entry_hash(),
1714 "A present lineage field must change the hash"
1715 );
1716 }
1717
1718 #[test]
1719 fn serde_round_trip_with_lineage() {
1720 let lineage = Lineage {
1721 root_agent_id: Some(ROOT),
1722 parent_agent_id: Some(PARENT),
1723 team_id: Some("t1".into()),
1724 org_id: Some("o1".into()),
1725 delegation_reason: Some("r".into()),
1726 spawned_by_tool: Some("s".into()),
1727 depth: Some(3),
1728 };
1729 let entry = AuditEntry::new_with_lineage(
1730 0,
1731 1_000,
1732 AuditEventType::ToolCallIntercepted,
1733 AGENT,
1734 SESSION,
1735 "{}".into(),
1736 [0u8; 32],
1737 lineage,
1738 );
1739 let json = serde_json::to_string(&entry).unwrap();
1740 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1741 assert_eq!(entry.entry_hash(), restored.entry_hash());
1742 assert_eq!(restored.root_agent_id(), Some(ROOT));
1743 assert_eq!(restored.depth(), Some(3));
1744 }
1745
1746 #[test]
1747 fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
1748 let pre_change_entry = AuditEntry::new(
1749 0,
1750 1_700_000_000_000_000_000,
1751 AuditEventType::ToolCallIntercepted,
1752 AGENT,
1753 SESSION,
1754 r#"{"tool":"bash"}"#.into(),
1755 [0u8; 32],
1756 );
1757 let json = serde_json::to_string(&pre_change_entry).unwrap();
1758 assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
1759 let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1760 assert!(restored.root_agent_id().is_none());
1761 assert!(
1762 restored.verify_integrity(),
1763 "Legacy entries must still verify after adding lineage fields"
1764 );
1765 }
1766
1767 #[test]
1768 fn next_entry_with_lineage_links_chain() {
1769 let mut log = AuditLog::new(AGENT, SESSION);
1770 let lineage = Lineage {
1771 depth: Some(1),
1772 team_id: Some("t".into()),
1773 ..Lineage::default()
1774 };
1775 log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
1776 log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
1777 assert!(log.verify_chain());
1778 assert_eq!(log.len(), 2);
1779 }
1780}
1781
1782#[cfg(all(test, feature = "std", feature = "serde"))]
1783mod redaction_tests {
1784 use super::*;
1785 use aa_security::CredentialScanner;
1786
1787 const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
1788 const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);
1789
1790 const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
1792
1793 fn build_redaction_for_fake_secret() -> Redaction {
1794 let scanner = CredentialScanner::new();
1795 let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
1796 assert!(
1797 !scan.findings.is_empty(),
1798 "scanner must detect the synthetic AWS access key — fixture invariant",
1799 );
1800 let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
1801 Redaction {
1802 credential_findings: scan.findings,
1803 redacted_payload: Some(redacted),
1804 }
1805 }
1806
1807 #[test]
1808 fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
1809 let redaction = build_redaction_for_fake_secret();
1810 let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
1812 let entry = AuditEntry::new_with_lineage_and_redaction(
1813 0,
1814 1_700_000_000_000_000_000,
1815 AuditEventType::CredentialLeakBlocked,
1816 AGENT,
1817 SESSION,
1818 payload,
1819 [0u8; 32],
1820 Lineage::default(),
1821 redaction,
1822 );
1823
1824 let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");
1825
1826 assert!(
1830 !serialized.contains(FAKE_AWS_ACCESS_KEY),
1831 "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
1832 );
1833
1834 assert!(
1836 serialized.contains("[REDACTED:AwsAccessKey]"),
1837 "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
1838 );
1839
1840 assert!(
1842 entry.verify_integrity(),
1843 "verify_integrity must pass on a freshly constructed redacted entry",
1844 );
1845 }
1846
1847 #[test]
1848 fn redaction_default_preserves_legacy_hash() {
1849 let payload = String::from(r#"{"tool":"bash"}"#);
1853 let legacy = AuditEntry::new(
1854 0,
1855 1_700_000_000_000_000_000,
1856 AuditEventType::ToolCallIntercepted,
1857 AGENT,
1858 SESSION,
1859 payload.clone(),
1860 [0u8; 32],
1861 );
1862 let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
1863 0,
1864 1_700_000_000_000_000_000,
1865 AuditEventType::ToolCallIntercepted,
1866 AGENT,
1867 SESSION,
1868 payload,
1869 [0u8; 32],
1870 Lineage::default(),
1871 Redaction::default(),
1872 );
1873 assert_eq!(
1874 legacy.entry_hash(),
1875 with_default_redaction.entry_hash(),
1876 "Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
1877 );
1878 }
1879}