use alloc::string::String;
use sha2::{Digest, Sha256};
use crate::{AgentId, SessionId};
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AuditEventType {
ToolCallIntercepted = 0,
PolicyViolation = 1,
CredentialLeakBlocked = 2,
ApprovalRequested = 3,
ApprovalGranted = 4,
ApprovalDenied = 5,
BudgetLimitApproached = 6,
BudgetLimitExceeded = 7,
ApprovalTimedOut = 8,
ApprovalRouted = 9,
ApprovalEscalated = 10,
AgentForceDeregistered = 11,
MessageBlocked = 12,
ToolDispatched = 13,
A2ACallIntercepted = 14,
A2AImpersonationAttempted = 15,
SandboxStarted = 16,
SandboxFilesystemBlocked = 17,
SandboxCpuTimeout = 18,
SandboxOomKilled = 19,
SandboxTerminated = 20,
}
impl AuditEventType {
pub fn as_str(&self) -> &'static str {
match self {
Self::ToolCallIntercepted => "ToolCallIntercepted",
Self::PolicyViolation => "PolicyViolation",
Self::CredentialLeakBlocked => "CredentialLeakBlocked",
Self::ApprovalRequested => "ApprovalRequested",
Self::ApprovalGranted => "ApprovalGranted",
Self::ApprovalDenied => "ApprovalDenied",
Self::BudgetLimitApproached => "BudgetLimitApproached",
Self::BudgetLimitExceeded => "BudgetLimitExceeded",
Self::ApprovalTimedOut => "ApprovalTimedOut",
Self::ApprovalRouted => "ApprovalRouted",
Self::ApprovalEscalated => "ApprovalEscalated",
Self::AgentForceDeregistered => "AgentForceDeregistered",
Self::MessageBlocked => "MessageBlocked",
Self::ToolDispatched => "ToolDispatched",
Self::A2ACallIntercepted => "A2ACallIntercepted",
Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
Self::SandboxStarted => "SandboxStarted",
Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
Self::SandboxCpuTimeout => "SandboxCpuTimeout",
Self::SandboxOomKilled => "SandboxOomKilled",
Self::SandboxTerminated => "SandboxTerminated",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Lineage {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub root_agent_id: Option<AgentId>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub parent_agent_id: Option<AgentId>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub team_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub org_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub delegation_reason: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub spawned_by_tool: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
pub depth: Option<u32>,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Redaction {
pub credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
pub redacted_payload: Option<alloc::string::String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AuditEntry {
seq: u64,
timestamp_ns: u64,
event_type: AuditEventType,
agent_id: AgentId,
session_id: SessionId,
payload: String,
previous_hash: [u8; 32],
entry_hash: [u8; 32],
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
root_agent_id: Option<AgentId>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
parent_agent_id: Option<AgentId>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
team_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
org_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
delegation_reason: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
spawned_by_tool: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
depth: Option<u32>,
#[cfg(feature = "std")]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
#[cfg(feature = "std")]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
redacted_payload: Option<String>,
}
impl AuditEntry {
pub fn new(
seq: u64,
timestamp_ns: u64,
event_type: AuditEventType,
agent_id: AgentId,
session_id: SessionId,
payload: String,
previous_hash: [u8; 32],
) -> Self {
let entry_hash = Self::compute_hash(
seq,
timestamp_ns,
&event_type,
&agent_id,
&session_id,
&previous_hash,
&payload,
&Lineage::default(),
#[cfg(feature = "std")]
&Redaction::default(),
);
Self {
seq,
timestamp_ns,
event_type,
agent_id,
session_id,
payload,
previous_hash,
entry_hash,
root_agent_id: None,
parent_agent_id: None,
team_id: None,
org_id: None,
delegation_reason: None,
spawned_by_tool: None,
depth: None,
#[cfg(feature = "std")]
credential_findings: alloc::vec::Vec::new(),
#[cfg(feature = "std")]
redacted_payload: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_lineage(
seq: u64,
timestamp_ns: u64,
event_type: AuditEventType,
agent_id: AgentId,
session_id: SessionId,
payload: String,
previous_hash: [u8; 32],
lineage: Lineage,
) -> Self {
let entry_hash = Self::compute_hash(
seq,
timestamp_ns,
&event_type,
&agent_id,
&session_id,
&previous_hash,
&payload,
&lineage,
#[cfg(feature = "std")]
&Redaction::default(),
);
Self {
seq,
timestamp_ns,
event_type,
agent_id,
session_id,
payload,
previous_hash,
entry_hash,
root_agent_id: lineage.root_agent_id,
parent_agent_id: lineage.parent_agent_id,
team_id: lineage.team_id,
org_id: lineage.org_id,
delegation_reason: lineage.delegation_reason,
spawned_by_tool: lineage.spawned_by_tool,
depth: lineage.depth,
#[cfg(feature = "std")]
credential_findings: alloc::vec::Vec::new(),
#[cfg(feature = "std")]
redacted_payload: None,
}
}
#[cfg(feature = "std")]
#[allow(clippy::too_many_arguments)]
pub fn new_with_lineage_and_redaction(
seq: u64,
timestamp_ns: u64,
event_type: AuditEventType,
agent_id: AgentId,
session_id: SessionId,
payload: String,
previous_hash: [u8; 32],
lineage: Lineage,
redaction: Redaction,
) -> Self {
let entry_hash = Self::compute_hash(
seq,
timestamp_ns,
&event_type,
&agent_id,
&session_id,
&previous_hash,
&payload,
&lineage,
&redaction,
);
Self {
seq,
timestamp_ns,
event_type,
agent_id,
session_id,
payload,
previous_hash,
entry_hash,
root_agent_id: lineage.root_agent_id,
parent_agent_id: lineage.parent_agent_id,
team_id: lineage.team_id,
org_id: lineage.org_id,
delegation_reason: lineage.delegation_reason,
spawned_by_tool: lineage.spawned_by_tool,
depth: lineage.depth,
credential_findings: redaction.credential_findings,
redacted_payload: redaction.redacted_payload,
}
}
#[inline]
pub fn seq(&self) -> u64 {
self.seq
}
#[inline]
pub fn timestamp_ns(&self) -> u64 {
self.timestamp_ns
}
#[inline]
pub fn event_type(&self) -> AuditEventType {
self.event_type
}
#[inline]
pub fn agent_id(&self) -> AgentId {
self.agent_id
}
#[inline]
pub fn session_id(&self) -> SessionId {
self.session_id
}
#[inline]
pub fn payload(&self) -> &str {
&self.payload
}
#[inline]
pub fn previous_hash(&self) -> &[u8; 32] {
&self.previous_hash
}
#[inline]
pub fn entry_hash(&self) -> &[u8; 32] {
&self.entry_hash
}
#[inline]
pub fn root_agent_id(&self) -> Option<AgentId> {
self.root_agent_id
}
#[inline]
pub fn parent_agent_id(&self) -> Option<AgentId> {
self.parent_agent_id
}
#[inline]
pub fn team_id(&self) -> Option<&str> {
self.team_id.as_deref()
}
#[inline]
pub fn org_id(&self) -> Option<&str> {
self.org_id.as_deref()
}
#[inline]
pub fn delegation_reason(&self) -> Option<&str> {
self.delegation_reason.as_deref()
}
#[inline]
pub fn spawned_by_tool(&self) -> Option<&str> {
self.spawned_by_tool.as_deref()
}
#[inline]
pub fn depth(&self) -> Option<u32> {
self.depth
}
#[cfg(feature = "std")]
#[inline]
pub fn credential_findings(&self) -> &[crate::scanner::CredentialFinding] {
&self.credential_findings
}
#[cfg(feature = "std")]
#[inline]
pub fn redacted_payload(&self) -> Option<&str> {
self.redacted_payload.as_deref()
}
pub fn verify_integrity(&self) -> bool {
let lineage = Lineage {
root_agent_id: self.root_agent_id,
parent_agent_id: self.parent_agent_id,
team_id: self.team_id.clone(),
org_id: self.org_id.clone(),
delegation_reason: self.delegation_reason.clone(),
spawned_by_tool: self.spawned_by_tool.clone(),
depth: self.depth,
};
#[cfg(feature = "std")]
let redaction = Redaction {
credential_findings: self.credential_findings.clone(),
redacted_payload: self.redacted_payload.clone(),
};
let expected = Self::compute_hash(
self.seq,
self.timestamp_ns,
&self.event_type,
&self.agent_id,
&self.session_id,
&self.previous_hash,
&self.payload,
&lineage,
#[cfg(feature = "std")]
&redaction,
);
expected == self.entry_hash
}
#[allow(clippy::too_many_arguments)]
fn compute_hash(
seq: u64,
timestamp_ns: u64,
event_type: &AuditEventType,
agent_id: &AgentId,
session_id: &SessionId,
previous_hash: &[u8; 32],
payload: &str,
lineage: &Lineage,
#[cfg(feature = "std")] redaction: &Redaction,
) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(seq.to_be_bytes());
hasher.update(timestamp_ns.to_be_bytes());
hasher.update((*event_type as u32).to_be_bytes());
hasher.update(agent_id.as_bytes());
hasher.update(session_id.as_bytes());
hasher.update(previous_hash);
hasher.update(payload.as_bytes());
if let Some(id) = &lineage.root_agent_id {
hasher.update(id.as_bytes());
}
if let Some(id) = &lineage.parent_agent_id {
hasher.update(id.as_bytes());
}
if let Some(s) = &lineage.team_id {
hasher.update((s.len() as u32).to_be_bytes());
hasher.update(s.as_bytes());
}
if let Some(s) = &lineage.delegation_reason {
hasher.update((s.len() as u32).to_be_bytes());
hasher.update(s.as_bytes());
}
if let Some(s) = &lineage.spawned_by_tool {
hasher.update((s.len() as u32).to_be_bytes());
hasher.update(s.as_bytes());
}
if let Some(d) = lineage.depth {
hasher.update(d.to_be_bytes());
}
if let Some(s) = &lineage.org_id {
hasher.update((s.len() as u32).to_be_bytes());
hasher.update(s.as_bytes());
}
#[cfg(feature = "std")]
{
if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
for finding in &redaction.credential_findings {
hasher.update((finding.offset as u64).to_be_bytes());
hasher.update((finding.matched.len() as u32).to_be_bytes());
hasher.update(finding.matched.as_bytes());
}
if let Some(s) = &redaction.redacted_payload {
hasher.update([1u8]);
hasher.update((s.len() as u32).to_be_bytes());
hasher.update(s.as_bytes());
} else {
hasher.update([0u8]);
}
}
}
hasher.finalize().into()
}
}
#[cfg(feature = "std")]
pub fn audit_entry_for_tool_dispatch(
seq: u64,
timestamp_ns: u64,
agent_id: AgentId,
session_id: SessionId,
placeholder_args: &serde_json::Value,
previous_hash: [u8; 32],
) -> AuditEntry {
let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
String::from("{\"error\":\"failed to serialize placeholder args\"}")
});
AuditEntry::new(
seq,
timestamp_ns,
AuditEventType::ToolDispatched,
agent_id,
session_id,
payload,
previous_hash,
)
}
impl core::fmt::Display for AuditEntry {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
for b in self.agent_id.as_bytes() {
write!(f, "{:02x}", b)?;
}
write!(f, " session=")?;
for b in self.session_id.as_bytes() {
write!(f, "{:02x}", b)?;
}
write!(f, " event={}]", self.event_type.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditLogError {
SequenceGap {
expected: u64,
got: u64,
},
HashChainBroken {
at_seq: u64,
},
}
impl core::fmt::Display for AuditLogError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::SequenceGap { expected, got } => {
write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
}
Self::HashChainBroken { at_seq } => {
write!(f, "audit log hash chain broken at seq={at_seq}")
}
}
}
}
pub struct AuditLog {
agent_id: AgentId,
session_id: SessionId,
entries: alloc::vec::Vec<AuditEntry>,
next_seq: u64,
last_hash: [u8; 32],
}
impl AuditLog {
pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
Self {
agent_id,
session_id,
entries: alloc::vec::Vec::new(),
next_seq: 0,
last_hash: [0u8; 32],
}
}
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn agent_id(&self) -> AgentId {
self.agent_id
}
pub fn session_id(&self) -> SessionId {
self.session_id
}
pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
if entry.seq() != self.next_seq {
return Err(AuditLogError::SequenceGap {
expected: self.next_seq,
got: entry.seq(),
});
}
if entry.previous_hash() != &self.last_hash {
return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
}
self.last_hash = *entry.entry_hash();
self.next_seq += 1;
self.entries.push(entry);
Ok(())
}
pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
let entry = AuditEntry::new(
self.next_seq,
timestamp_ns,
event_type,
self.agent_id,
self.session_id,
payload,
self.last_hash,
);
self.push(entry).expect("next_entry invariant: push cannot fail");
self.entries.last().expect("entry was just pushed")
}
pub fn next_entry_with_lineage(
&mut self,
event_type: AuditEventType,
timestamp_ns: u64,
payload: String,
lineage: Lineage,
) -> &AuditEntry {
let entry = AuditEntry::new_with_lineage(
self.next_seq,
timestamp_ns,
event_type,
self.agent_id,
self.session_id,
payload,
self.last_hash,
lineage,
);
self.push(entry)
.expect("next_entry_with_lineage invariant: push cannot fail");
self.entries.last().expect("entry was just pushed")
}
pub fn verify_chain(&self) -> bool {
let mut expected_prev_hash: [u8; 32] = [0u8; 32];
for (expected_seq, entry) in self.entries.iter().enumerate() {
if !entry.verify_integrity() {
return false;
}
if entry.seq() != expected_seq as u64 {
return false;
}
if entry.previous_hash() != &expected_prev_hash {
return false;
}
expected_prev_hash = *entry.entry_hash();
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
const GENESIS_HASH: [u8; 32] = [0u8; 32];
fn make_entry(seq: u64) -> AuditEntry {
AuditEntry::new(
seq,
1_714_222_134_000_000_000,
AuditEventType::ToolCallIntercepted,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
GENESIS_HASH,
)
}
#[test]
fn event_type_as_str_all_variants() {
assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
assert_eq!(
AuditEventType::SandboxFilesystemBlocked.as_str(),
"SandboxFilesystemBlocked"
);
assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
}
#[test]
fn event_type_discriminants_are_0_through_10() {
assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
assert_eq!(AuditEventType::PolicyViolation as u32, 1);
assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
assert_eq!(AuditEventType::ToolDispatched as u32, 13);
assert_eq!(AuditEventType::SandboxStarted as u32, 16);
assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
}
#[test]
fn event_type_variants_are_all_distinct() {
let variants = [
AuditEventType::ToolCallIntercepted,
AuditEventType::PolicyViolation,
AuditEventType::CredentialLeakBlocked,
AuditEventType::ApprovalRequested,
AuditEventType::ApprovalGranted,
AuditEventType::ApprovalDenied,
AuditEventType::BudgetLimitApproached,
AuditEventType::BudgetLimitExceeded,
AuditEventType::ApprovalTimedOut,
AuditEventType::ApprovalRouted,
AuditEventType::ApprovalEscalated,
AuditEventType::ToolDispatched,
AuditEventType::SandboxStarted,
AuditEventType::SandboxFilesystemBlocked,
AuditEventType::SandboxCpuTimeout,
AuditEventType::SandboxOomKilled,
AuditEventType::SandboxTerminated,
];
for i in 0..variants.len() {
for j in (i + 1)..variants.len() {
assert_ne!(variants[i], variants[j]);
}
}
}
#[test]
fn new_produces_nonzero_entry_hash() {
let entry = make_entry(0);
assert_ne!(entry.entry_hash(), &[0u8; 32]);
}
#[test]
fn getters_return_correct_values() {
let payload = alloc::string::String::from("{\"k\":\"v\"}");
let entry = AuditEntry::new(
42,
999_000_000,
AuditEventType::PolicyViolation,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
payload.clone(),
GENESIS_HASH,
);
assert_eq!(entry.seq(), 42);
assert_eq!(entry.timestamp_ns(), 999_000_000);
assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
assert_eq!(entry.payload(), "{\"k\":\"v\"}");
assert_eq!(entry.previous_hash(), &GENESIS_HASH);
}
#[test]
fn genesis_entry_uses_zero_previous_hash() {
let entry = make_entry(0);
assert_eq!(entry.previous_hash(), &[0u8; 32]);
}
#[test]
fn verify_integrity_true_for_untampered_entry() {
assert!(make_entry(0).verify_integrity());
}
#[test]
fn verify_integrity_false_after_seq_tamper() {
let mut entry = make_entry(0);
unsafe {
let ptr = &mut entry.seq as *mut u64;
*ptr = 999;
}
assert!(!entry.verify_integrity());
}
#[test]
fn verify_integrity_false_after_payload_tamper() {
let mut entry = make_entry(0);
unsafe {
let ptr = entry.payload.as_mut_vec();
if let Some(b) = ptr.first_mut() {
*b = b'X';
}
}
assert!(!entry.verify_integrity());
}
#[test]
fn verify_integrity_false_after_event_type_tamper() {
let mut entry = make_entry(0);
unsafe {
let ptr = &mut entry.event_type as *mut AuditEventType;
*ptr = AuditEventType::BudgetLimitExceeded;
}
assert!(!entry.verify_integrity());
}
#[test]
fn verify_integrity_false_after_previous_hash_tamper() {
let mut entry = make_entry(0);
unsafe {
let ptr = &mut entry.previous_hash as *mut [u8; 32];
(*ptr)[0] = 0xFF;
}
assert!(!entry.verify_integrity());
}
#[test]
fn chained_entries_have_distinct_hashes() {
let first = make_entry(0);
let second = AuditEntry::new(
1,
1_714_222_134_000_000_001,
AuditEventType::PolicyViolation,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{\"rule\":\"deny\"}"),
*first.entry_hash(),
);
assert_ne!(first.entry_hash(), second.entry_hash());
assert_eq!(second.previous_hash(), first.entry_hash());
assert!(second.verify_integrity());
}
#[test]
fn different_seq_produces_different_hash() {
let a = make_entry(0);
let b = make_entry(1);
assert_ne!(a.entry_hash(), b.entry_hash());
}
#[test]
fn different_previous_hash_produces_different_entry_hash() {
let prev_a = [0u8; 32];
let mut prev_b = [0u8; 32];
prev_b[0] = 1;
let a = AuditEntry::new(
0,
0,
AuditEventType::ToolCallIntercepted,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{}"),
prev_a,
);
let b = AuditEntry::new(
0,
0,
AuditEventType::ToolCallIntercepted,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{}"),
prev_b,
);
assert_ne!(a.entry_hash(), b.entry_hash());
}
#[test]
fn display_contains_seq_ts_and_event_name() {
let entry = make_entry(7);
let s = alloc::format!("{}", entry);
assert!(s.starts_with('['));
assert!(s.ends_with(']'));
assert!(s.contains("seq=7"));
assert!(s.contains("ts=1714222134000000000"));
assert!(s.contains("event=ToolCallIntercepted"));
}
#[test]
fn display_contains_agent_and_session_hex() {
let entry = make_entry(0);
let s = alloc::format!("{}", entry);
assert!(s.contains("agent=01020304"));
assert!(s.contains("session=11121314"));
}
#[test]
fn display_does_not_contain_payload() {
let entry = make_entry(0);
let s = alloc::format!("{}", entry);
assert!(!s.contains("bash"));
}
#[test]
fn display_round_trips_sandbox_event_names() {
let sandbox_events = [
(AuditEventType::SandboxStarted, "event=SandboxStarted]"),
(
AuditEventType::SandboxFilesystemBlocked,
"event=SandboxFilesystemBlocked]",
),
(AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
(AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
(AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
];
for (event_type, expected_tail) in sandbox_events {
let entry = AuditEntry::new(
0,
1_714_222_134_000_000_000,
event_type,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{}"),
GENESIS_HASH,
);
let rendered = alloc::format!("{}", entry);
assert!(
rendered.ends_with(expected_tail),
"Display for {:?} should end with `{}` but was `{}`",
event_type,
expected_tail,
rendered,
);
}
}
fn make_log() -> AuditLog {
AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
}
fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
AuditEntry::new(
seq,
1_000_000_000,
AuditEventType::ToolCallIntercepted,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
alloc::string::String::from("{}"),
previous_hash,
)
}
#[test]
fn push_genesis_entry_succeeds() {
let mut log = make_log();
let entry = make_valid_entry(0, GENESIS_HASH);
assert!(log.push(entry).is_ok());
assert_eq!(log.len(), 1);
}
#[test]
fn push_rejects_seq_gap_skipping_forward() {
let mut log = make_log();
let entry = make_valid_entry(2, GENESIS_HASH); let err = log.push(entry).unwrap_err();
assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
assert!(log.is_empty(), "log must be unmodified on error");
}
#[test]
fn push_rejects_seq_going_backward() {
let mut log = make_log();
let e0 = make_valid_entry(0, GENESIS_HASH);
let hash0 = *e0.entry_hash();
log.push(e0).unwrap();
let e_back = make_valid_entry(0, hash0); let err = log.push(e_back).unwrap_err();
assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
assert_eq!(log.len(), 1, "log must be unmodified on error");
}
#[test]
fn push_rejects_broken_hash_chain() {
let mut log = make_log();
let e0 = make_valid_entry(0, GENESIS_HASH);
log.push(e0).unwrap();
let wrong_prev = [0xAB; 32]; let e1 = make_valid_entry(1, wrong_prev);
let err = log.push(e1).unwrap_err();
assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
assert_eq!(log.len(), 1, "log must be unmodified on error");
}
#[test]
fn push_two_valid_entries_succeeds() {
let mut log = make_log();
let e0 = make_valid_entry(0, GENESIS_HASH);
let hash0 = *e0.entry_hash();
log.push(e0).unwrap();
let e1 = make_valid_entry(1, hash0);
log.push(e1).unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log.entries()[0].seq(), 0);
assert_eq!(log.entries()[1].seq(), 1);
}
#[test]
fn audit_log_error_display_sequence_gap() {
let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
let s = alloc::format!("{}", err);
assert!(s.contains("expected seq=3"));
assert!(s.contains("got seq=7"));
}
#[test]
fn audit_log_error_display_hash_chain_broken() {
let err = AuditLogError::HashChainBroken { at_seq: 5 };
let s = alloc::format!("{}", err);
assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
}
#[test]
fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
let mut log = make_log();
let e = log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
assert_eq!(e.seq(), 0);
assert_eq!(e.previous_hash(), &GENESIS_HASH);
assert!(e.verify_integrity());
}
#[test]
fn next_entry_auto_increments_seq() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::PolicyViolation,
2_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::ApprovalGranted,
3_000,
alloc::string::String::from("{}"),
);
assert_eq!(log.len(), 3);
assert_eq!(log.entries()[0].seq(), 0);
assert_eq!(log.entries()[1].seq(), 1);
assert_eq!(log.entries()[2].seq(), 2);
}
#[test]
fn next_entry_links_previous_hash_correctly() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::PolicyViolation,
2_000,
alloc::string::String::from("{}"),
);
let e0_hash = *log.entries()[0].entry_hash();
assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
}
#[test]
fn next_entry_mixed_with_push_works_correctly() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
let hash0 = *log.entries()[0].entry_hash();
let e1 = make_valid_entry(1, hash0);
log.push(e1).unwrap();
log.next_entry(
AuditEventType::ApprovalGranted,
3_000,
alloc::string::String::from("{}"),
);
assert_eq!(log.len(), 3);
assert_eq!(log.entries()[2].seq(), 2);
assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
}
#[test]
fn next_entry_all_entries_pass_verify_integrity() {
let mut log = make_log();
for i in 0..5 {
log.next_entry(
AuditEventType::ToolCallIntercepted,
i * 1_000,
alloc::string::String::from("{}"),
);
}
for entry in log.entries() {
assert!(entry.verify_integrity());
}
}
#[test]
fn verify_chain_empty_log_returns_true() {
assert!(make_log().verify_chain());
}
#[test]
fn verify_chain_valid_log_returns_true() {
let mut log = make_log();
for i in 0..4 {
log.next_entry(
AuditEventType::ToolCallIntercepted,
i * 1_000,
alloc::string::String::from("{}"),
);
}
assert!(log.verify_chain());
}
#[test]
fn verify_chain_false_after_unsafe_seq_tamper() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::PolicyViolation,
2_000,
alloc::string::String::from("{}"),
);
unsafe {
let entry = &mut *(log.entries.as_mut_ptr());
let ptr = &mut entry.seq as *mut u64;
*ptr = 99;
}
assert!(!log.verify_chain());
}
#[test]
fn verify_chain_false_after_unsafe_payload_tamper() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::PolicyViolation,
2_000,
alloc::string::String::from("{}"),
);
unsafe {
let entry = &mut *(log.entries.as_mut_ptr().add(1));
if let Some(b) = entry.payload.as_mut_vec().first_mut() {
*b = b'X';
}
}
assert!(!log.verify_chain());
}
#[test]
fn verify_chain_false_after_unsafe_previous_hash_tamper() {
let mut log = make_log();
log.next_entry(
AuditEventType::ToolCallIntercepted,
1_000,
alloc::string::String::from("{}"),
);
log.next_entry(
AuditEventType::PolicyViolation,
2_000,
alloc::string::String::from("{}"),
);
unsafe {
let entry = &mut *(log.entries.as_mut_ptr().add(1));
let ptr = &mut entry.previous_hash as *mut [u8; 32];
(*ptr)[0] = 0xFF;
}
assert!(!log.verify_chain());
}
#[test]
fn tool_dispatch_helper_emits_placeholder_form_payload() {
let real_secret = "real-secret-abc-DEADBEEF-0001";
let placeholder_args = serde_json::json!({
"connection_string": "${DB_PASSWORD}"
});
let entry = audit_entry_for_tool_dispatch(
42,
1_714_222_134_000_000_000,
AgentId::from_bytes(AGENT_BYTES),
SessionId::from_bytes(SESSION_BYTES),
&placeholder_args,
GENESIS_HASH,
);
assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
assert!(entry.payload().contains("${DB_PASSWORD}"));
assert!(
!entry.payload().contains(real_secret),
"audit payload MUST NOT contain the resolved credential — placeholder-form contract"
);
}
}
#[cfg(all(test, feature = "alloc", feature = "serde"))]
mod lineage_tests {
use super::*;
const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);
fn base_entry() -> AuditEntry {
AuditEntry::new(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
r#"{"tool":"bash"}"#.into(),
[0u8; 32],
)
}
#[test]
fn lineage_default_is_all_none() {
let l = Lineage::default();
assert!(l.root_agent_id.is_none());
assert!(l.parent_agent_id.is_none());
assert!(l.team_id.is_none());
assert!(l.delegation_reason.is_none());
assert!(l.spawned_by_tool.is_none());
assert!(l.depth.is_none());
}
#[test]
fn new_with_empty_lineage_produces_same_hash_as_new() {
let legacy = base_entry();
let with_lineage = AuditEntry::new_with_lineage(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
r#"{"tool":"bash"}"#.into(),
[0u8; 32],
Lineage::default(),
);
assert_eq!(
legacy.entry_hash(),
with_lineage.entry_hash(),
"Lineage::default() must not change the hash"
);
}
#[test]
fn new_with_lineage_getters_return_correct_values() {
let lineage = Lineage {
root_agent_id: Some(ROOT),
parent_agent_id: Some(PARENT),
team_id: Some("team-alpha".into()),
org_id: None,
delegation_reason: Some("summarise".into()),
spawned_by_tool: Some("langgraph".into()),
depth: Some(2),
};
let entry = AuditEntry::new_with_lineage(
0,
1_000,
AuditEventType::PolicyViolation,
AGENT,
SESSION,
"{}".into(),
[0u8; 32],
lineage,
);
assert_eq!(entry.root_agent_id(), Some(ROOT));
assert_eq!(entry.parent_agent_id(), Some(PARENT));
assert_eq!(entry.team_id(), Some("team-alpha"));
assert_eq!(entry.delegation_reason(), Some("summarise"));
assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
assert_eq!(entry.depth(), Some(2));
}
#[test]
fn verify_integrity_true_with_lineage() {
let lineage = Lineage {
root_agent_id: Some(ROOT),
team_id: Some("ops".into()),
depth: Some(1),
..Lineage::default()
};
let entry = AuditEntry::new_with_lineage(
0,
1_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
"{}".into(),
[0u8; 32],
lineage,
);
assert!(entry.verify_integrity());
}
#[test]
fn lineage_fields_change_hash() {
let no_lineage = base_entry();
let lineage = Lineage {
depth: Some(1),
..Lineage::default()
};
let with_depth = AuditEntry::new_with_lineage(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
r#"{"tool":"bash"}"#.into(),
[0u8; 32],
lineage,
);
assert_ne!(
no_lineage.entry_hash(),
with_depth.entry_hash(),
"A present lineage field must change the hash"
);
}
#[test]
fn serde_round_trip_with_lineage() {
let lineage = Lineage {
root_agent_id: Some(ROOT),
parent_agent_id: Some(PARENT),
team_id: Some("t1".into()),
org_id: Some("o1".into()),
delegation_reason: Some("r".into()),
spawned_by_tool: Some("s".into()),
depth: Some(3),
};
let entry = AuditEntry::new_with_lineage(
0,
1_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
"{}".into(),
[0u8; 32],
lineage,
);
let json = serde_json::to_string(&entry).unwrap();
let restored: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry.entry_hash(), restored.entry_hash());
assert_eq!(restored.root_agent_id(), Some(ROOT));
assert_eq!(restored.depth(), Some(3));
}
#[test]
fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
let pre_change_entry = AuditEntry::new(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
r#"{"tool":"bash"}"#.into(),
[0u8; 32],
);
let json = serde_json::to_string(&pre_change_entry).unwrap();
assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
let restored: AuditEntry = serde_json::from_str(&json).unwrap();
assert!(restored.root_agent_id().is_none());
assert!(
restored.verify_integrity(),
"Legacy entries must still verify after adding lineage fields"
);
}
#[test]
fn next_entry_with_lineage_links_chain() {
let mut log = AuditLog::new(AGENT, SESSION);
let lineage = Lineage {
depth: Some(1),
team_id: Some("t".into()),
..Lineage::default()
};
log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
assert!(log.verify_chain());
assert_eq!(log.len(), 2);
}
}
#[cfg(all(test, feature = "std", feature = "serde"))]
mod redaction_tests {
use super::*;
use crate::scanner::CredentialScanner;
const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);
const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
fn build_redaction_for_fake_secret() -> Redaction {
let scanner = CredentialScanner::new();
let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
assert!(
!scan.findings.is_empty(),
"scanner must detect the synthetic AWS access key — fixture invariant",
);
let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
Redaction {
credential_findings: scan.findings,
redacted_payload: Some(redacted),
}
}
#[test]
fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
let redaction = build_redaction_for_fake_secret();
let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
let entry = AuditEntry::new_with_lineage_and_redaction(
0,
1_700_000_000_000_000_000,
AuditEventType::CredentialLeakBlocked,
AGENT,
SESSION,
payload,
[0u8; 32],
Lineage::default(),
redaction,
);
let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");
assert!(
!serialized.contains(FAKE_AWS_ACCESS_KEY),
"SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
);
assert!(
serialized.contains("[REDACTED:AwsAccessKey]"),
"serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
);
assert!(
entry.verify_integrity(),
"verify_integrity must pass on a freshly constructed redacted entry",
);
}
#[test]
fn redaction_default_preserves_legacy_hash() {
let payload = String::from(r#"{"tool":"bash"}"#);
let legacy = AuditEntry::new(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
payload.clone(),
[0u8; 32],
);
let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
0,
1_700_000_000_000_000_000,
AuditEventType::ToolCallIntercepted,
AGENT,
SESSION,
payload,
[0u8; 32],
Lineage::default(),
Redaction::default(),
);
assert_eq!(
legacy.entry_hash(),
with_default_redaction.entry_hash(),
"Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
);
}
}