Skip to main content

aa_core/
audit.rs

1//! Immutable, hash-chained audit entry for Agent Assembly governance events.
2//!
3//! Each [`AuditEntry`] commits to all tamper-meaningful fields via a SHA-256 hash
4//! that includes the hash of the preceding entry, forming a tamper-evident chain.
5//!
6//! Gated on the `alloc` feature because [`AuditEntry::payload`] is an
7//! [`alloc::string::String`].
8
9use alloc::string::String;
10use sha2::{Digest, Sha256};
11
12use crate::{AgentId, SessionId};
13
14// ---------------------------------------------------------------------------
15// AuditEventType
16// ---------------------------------------------------------------------------
17
18/// Category of a governance event recorded in an [`AuditEntry`].
19///
20/// The `#[repr(u32)]` attribute makes `event_type as u32` the canonical
21/// 4-byte discriminant used in the SHA-256 hash input.
22#[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    /// A tool call was intercepted by the governance layer before execution.
28    ToolCallIntercepted = 0,
29    /// An evaluated action violated an active policy rule.
30    PolicyViolation = 1,
31    /// A credential or secret present in tool arguments was blocked.
32    CredentialLeakBlocked = 2,
33    /// Human approval was requested before the action could proceed.
34    ApprovalRequested = 3,
35    /// A pending human approval request was granted.
36    ApprovalGranted = 4,
37    /// A pending human approval request was denied.
38    ApprovalDenied = 5,
39    /// The session budget is approaching its configured limit.
40    BudgetLimitApproached = 6,
41    /// The session budget has been exhausted; further actions are blocked.
42    BudgetLimitExceeded = 7,
43    /// A pending human approval request expired before a decision was made.
44    ApprovalTimedOut = 8,
45    /// An approval request was routed to a team-specific approver queue.
46    ApprovalRouted = 9,
47    /// An approval request was escalated after the initial approver did not respond.
48    ApprovalEscalated = 10,
49    /// An agent was force-deregistered by the gateway because it exceeded its configured maximum age.
50    AgentForceDeregistered = 11,
51    /// An inter-team message was blocked because the target channel is not permitted by policy.
52    MessageBlocked = 12,
53    /// A `dispatch_tool` call was processed by the gateway: the agent's
54    /// placeholder-form args were resolved via the `SecretsStore` and
55    /// forwarded to the tool sink. The audit `payload` carries the
56    /// **placeholder-form** args — the resolved credential value is never
57    /// recorded. (AAASM-1920 / Secret Injection.)
58    ToolDispatched = 13,
59    /// An agent-to-agent (A2A) call was intercepted. The audit `payload`
60    /// carries both `caller_agent_id` (the originating agent) and
61    /// `callee_agent_id` (the agent performing the action), so reviewers
62    /// can reconstruct cross-agent delegation graphs even when the call
63    /// was allowed. Emitted only when the request's `caller_agent_id` is
64    /// populated and differs from `agent_id`. (AAASM-1944 / Zero-trust A2A.)
65    A2ACallIntercepted = 14,
66    /// An impersonation attempt was rejected: the request claimed an
67    /// `agent_id` whose registered `credential_token` does not match the
68    /// token supplied. The audit `payload` carries `claimed_agent_id` and
69    /// the agent whose `credential_token` was actually presented (when
70    /// resolvable). The action is denied before policy evaluation runs.
71    /// (AAASM-1944 / Zero-trust A2A.)
72    A2AImpersonationAttempted = 15,
73    /// A sandboxed (WASM/WASI) tool invocation has begun. Emitted by
74    /// `aa-proxy` immediately before handing the parsed `tools/call`
75    /// envelope to the `aa-sandbox` runtime for execution. Marks the
76    /// start of the lifecycle terminated by [`SandboxTerminated`],
77    /// [`SandboxFilesystemBlocked`], [`SandboxCpuTimeout`], or
78    /// [`SandboxOomKilled`]. (AAASM-1965 / F116 ST-W.)
79    ///
80    /// [`SandboxTerminated`]: Self::SandboxTerminated
81    /// [`SandboxFilesystemBlocked`]: Self::SandboxFilesystemBlocked
82    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
83    /// [`SandboxOomKilled`]: Self::SandboxOomKilled
84    SandboxStarted = 16,
85    /// A sandboxed tool attempted to read or write outside the WASI
86    /// preopened-dir allowlist. Surfaced by `aa-sandbox::runtime` when
87    /// `path_open` (or another `fd_*` host call) returns `EACCES`
88    /// because the requested path is not under any directory passed to
89    /// `WasiCtxBuilder::preopened_dir`. (AAASM-1965 / F116 ST-W,
90    /// Scenario 1 — filesystem isolation.)
91    SandboxFilesystemBlocked = 17,
92    /// A sandboxed tool was killed because its wasmtime instruction-fuel
93    /// budget (or wall-clock guard) was exhausted. Surfaced by
94    /// `aa-sandbox::runtime` when `Store::set_fuel` drains to zero or
95    /// the wall-clock watcher fires, mapping to
96    /// `SandboxError::CpuTimeout` / `SandboxError::WallClockTimeout`.
97    /// (AAASM-1965 / F116 ST-W, Scenario 2 — runaway-loop kill.)
98    SandboxCpuTimeout = 18,
99    /// A sandboxed tool was killed because wasmtime's `Store::limiter`
100    /// rejected a memory-growth request that would exceed the
101    /// configured `memory_pages` ceiling. Surfaced by
102    /// `aa-sandbox::runtime` mapping to `SandboxError::MemoryExhausted`.
103    /// Distinguished from [`SandboxCpuTimeout`] so audit consumers can
104    /// tell whether the host pressure was instruction-fuel or memory
105    /// pages. (AAASM-1965 / F116 ST-W, Scenario 2 — memory-bomb kill.)
106    ///
107    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
108    SandboxOomKilled = 19,
109    /// A sandboxed tool invocation completed without being killed by
110    /// any isolation primitive. Emitted by `aa-sandbox::runtime`
111    /// regardless of whether the tool returned a logical success or a
112    /// tool-defined error — this variant marks lifecycle completion,
113    /// not outcome semantics. (AAASM-1965 / F116 ST-W.)
114    SandboxTerminated = 20,
115}
116
117impl AuditEventType {
118    /// Returns the string label used in [`Display`] output and log messages.
119    ///
120    /// [`Display`]: core::fmt::Display
121    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// ---------------------------------------------------------------------------
149// Lineage
150// ---------------------------------------------------------------------------
151
152/// Optional agent-topology fields attached to an [`AuditEntry`].
153///
154/// All fields are `None` for entries emitted without an `AgentContext`
155/// (legacy path). `Lineage::default()` passed to
156/// [`AuditEntry::new_with_lineage`] produces a hash identical to
157/// [`AuditEntry::new`] with the same base fields.
158#[derive(Debug, Clone, PartialEq, Default)]
159#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
160pub struct Lineage {
161    /// Root agent identifier at the top of the delegation chain.
162    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
163    pub root_agent_id: Option<AgentId>,
164    /// Identifier of the agent that directly spawned this agent.
165    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
166    pub parent_agent_id: Option<AgentId>,
167    /// Team identifier associated with the agent that produced the entry.
168    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
169    pub team_id: Option<String>,
170    /// AAASM-2008 — organization identifier of the agent that produced the
171    /// entry. Mirrors `team_id` but at the multi-tenancy tier so audit logs,
172    /// compliance exports, and operator queries can filter / scope by Org.
173    /// Present when the gateway can resolve `org_id` from the agent's
174    /// registration metadata; `None` for entries emitted without an
175    /// `AgentContext` or registered without org metadata.
176    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
177    pub org_id: Option<String>,
178    /// Human-readable reason the action was delegated to this agent.
179    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
180    pub delegation_reason: Option<String>,
181    /// Name of the tool or framework that spawned this agent (e.g. `"langgraph"`).
182    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
183    pub spawned_by_tool: Option<String>,
184    /// Delegation depth from the root agent (`0` = root, `1` = first delegate, …).
185    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
186    pub depth: Option<u32>,
187}
188
189// ---------------------------------------------------------------------------
190// Redaction
191// ---------------------------------------------------------------------------
192
193// `AuditEntry` consumes the redaction primitive from the leaf crate
194// `aa-security` (AAASM-2567); it is imported privately here and is no longer
195// re-exported from `aa-core`. Consumers depend on `aa-security` directly.
196// Gated on `std` because `Redaction` holds `aa_security::CredentialFinding`
197// values, which live in the `std`-only `scanner` module.
198#[cfg(feature = "std")]
199use aa_security::Redaction;
200
201// ---------------------------------------------------------------------------
202// AuditEntry
203// ---------------------------------------------------------------------------
204
205/// An immutable, hash-chained record of a single governance event.
206///
207/// ## Immutability
208///
209/// All fields are private. The only way to create an [`AuditEntry`] is via
210/// [`AuditEntry::new`]. There are no mutation methods.
211///
212/// ## Hash chain
213///
214/// `entry_hash` is a SHA-256 digest computed over all tamper-meaningful fields
215/// in a canonical byte order (see [`AuditEntry::new`] for the full sequence).
216/// Each entry commits to `previous_hash`, linking entries into a tamper-evident
217/// chain. The genesis entry uses `[0u8; 32]` as `previous_hash`.
218///
219/// ## Tamper detection
220///
221/// [`AuditEntry::verify_integrity`] re-computes the hash from the stored fields
222/// and compares it to the stored `entry_hash`. Any field alteration — including
223/// via `unsafe` code — will cause the re-computed hash to diverge.
224#[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    /// AAASM-2008 — see [`Lineage::org_id`].
242    #[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    // -----------------------------------------------------------------------
260    // Constructor
261    // -----------------------------------------------------------------------
262
263    /// Create a new [`AuditEntry`], computing `entry_hash` over all fields.
264    ///
265    /// ## Parameters
266    ///
267    /// - `seq` — monotonic counter within the session; genesis entry is `0`.
268    /// - `timestamp_ns` — nanoseconds since the Unix epoch (caller-supplied;
269    ///   use `Timestamp::from(SystemTime::now()).as_nanos()` in `std` environments).
270    /// - `event_type` — category of the governance event.
271    /// - `agent_id` — identifier of the agent that produced the event.
272    /// - `session_id` — identifier of the specific agent run.
273    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
274    /// - `previous_hash` — `entry_hash` of the preceding entry;
275    ///   `[0u8; 32]` for the genesis entry.
276    ///
277    /// ## Canonical hash input (84 fixed bytes + variable payload)
278    ///
279    /// ```text
280    /// SHA-256(
281    ///     seq.to_be_bytes()                  //  8 bytes
282    ///     || timestamp_ns.to_be_bytes()      //  8 bytes
283    ///     || (event_type as u32).to_be_bytes() // 4 bytes
284    ///     || agent_id.as_bytes()             // 16 bytes
285    ///     || session_id.as_bytes()           // 16 bytes
286    ///     || previous_hash                   // 32 bytes
287    ///     || payload.as_bytes()              // variable
288    /// )
289    /// ```
290    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    /// Create a new [`AuditEntry`] with optional lineage fields, computing `entry_hash`
335    /// over all fields including the lineage data.
336    ///
337    /// When `lineage` is `Lineage::default()` (all fields `None`), the resulting
338    /// `entry_hash` is identical to that produced by [`AuditEntry::new`] with the
339    /// same base fields, preserving backward compatibility.
340    #[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    /// Create a new [`AuditEntry`] carrying both lineage data and credential
387    /// scanner output, computing `entry_hash` over all tamper-meaningful fields.
388    ///
389    /// When `redaction == Redaction::default()` (empty findings + `None` payload),
390    /// the resulting `entry_hash` is identical to [`AuditEntry::new_with_lineage`]
391    /// with the same base fields — so callers that don't have scanner data can
392    /// continue using the legacy constructors without any chain divergence.
393    ///
394    /// Gated on `std` because [`Redaction`] holds
395    /// [`CredentialFinding`](aa_security::CredentialFinding) values, which
396    /// live in the `std`-only `scanner` module.
397    #[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    // -----------------------------------------------------------------------
443    // Getters
444    // -----------------------------------------------------------------------
445
446    /// Monotonic sequence counter within the session.
447    #[inline]
448    pub fn seq(&self) -> u64 {
449        self.seq
450    }
451
452    /// Nanoseconds since the Unix epoch at the time the entry was created.
453    #[inline]
454    pub fn timestamp_ns(&self) -> u64 {
455        self.timestamp_ns
456    }
457
458    /// Category of the governance event.
459    #[inline]
460    pub fn event_type(&self) -> AuditEventType {
461        self.event_type
462    }
463
464    /// Identifier of the agent that produced this entry.
465    #[inline]
466    pub fn agent_id(&self) -> AgentId {
467        self.agent_id
468    }
469
470    /// Identifier of the specific agent run (session) that produced this entry.
471    #[inline]
472    pub fn session_id(&self) -> SessionId {
473        self.session_id
474    }
475
476    /// Pre-serialized UTF-8 payload (JSON in practice).
477    #[inline]
478    pub fn payload(&self) -> &str {
479        &self.payload
480    }
481
482    /// SHA-256 hash of the preceding entry; `[0u8; 32]` for the genesis entry.
483    #[inline]
484    pub fn previous_hash(&self) -> &[u8; 32] {
485        &self.previous_hash
486    }
487
488    /// SHA-256 hash computed over all tamper-meaningful fields at construction.
489    #[inline]
490    pub fn entry_hash(&self) -> &[u8; 32] {
491        &self.entry_hash
492    }
493
494    /// Root agent identifier in the delegation chain, if present.
495    #[inline]
496    pub fn root_agent_id(&self) -> Option<AgentId> {
497        self.root_agent_id
498    }
499
500    /// Parent agent identifier that directly spawned this agent, if present.
501    #[inline]
502    pub fn parent_agent_id(&self) -> Option<AgentId> {
503        self.parent_agent_id
504    }
505
506    /// Team identifier associated with the agent, if present.
507    #[inline]
508    pub fn team_id(&self) -> Option<&str> {
509        self.team_id.as_deref()
510    }
511
512    /// AAASM-2008 — organization identifier associated with the agent, if
513    /// present. Mirrors [`Self::team_id`] at the multi-tenancy tier.
514    #[inline]
515    pub fn org_id(&self) -> Option<&str> {
516        self.org_id.as_deref()
517    }
518
519    /// Reason this agent was delegated the action, if present.
520    #[inline]
521    pub fn delegation_reason(&self) -> Option<&str> {
522        self.delegation_reason.as_deref()
523    }
524
525    /// Name of the tool that spawned this agent, if present.
526    #[inline]
527    pub fn spawned_by_tool(&self) -> Option<&str> {
528        self.spawned_by_tool.as_deref()
529    }
530
531    /// Delegation depth from the root agent, if present.
532    #[inline]
533    pub fn depth(&self) -> Option<u32> {
534        self.depth
535    }
536
537    /// Credential / PII findings detected by the policy engine's scanner pass.
538    ///
539    /// Empty when the scan was clean (or when the entry was constructed via a
540    /// pre-redaction-aware code path). Each [`CredentialFinding`](aa_security::CredentialFinding)
541    /// stores only the kind, byte offset, and `[REDACTED:<kind>]` label —
542    /// never the raw secret bytes.
543    #[cfg(feature = "std")]
544    #[inline]
545    pub fn credential_findings(&self) -> &[aa_security::CredentialFinding] {
546        &self.credential_findings
547    }
548
549    /// Redacted version of the action payload, if the scanner produced findings.
550    ///
551    /// `None` when the scan was clean. When `Some`, every detected secret has
552    /// been replaced with its `[REDACTED:<kind>]` label so the audit trail
553    /// itself never leaks the raw secret.
554    #[cfg(feature = "std")]
555    #[inline]
556    pub fn redacted_payload(&self) -> Option<&str> {
557        self.redacted_payload.as_deref()
558    }
559
560    // -----------------------------------------------------------------------
561    // Integrity
562    // -----------------------------------------------------------------------
563
564    /// Returns `true` if the stored `entry_hash` matches a fresh re-computation
565    /// over the stored fields.
566    ///
567    /// Returns `false` if any field has been altered after construction — including
568    /// via `unsafe` code.
569    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    // -----------------------------------------------------------------------
600    // Private helpers
601    // -----------------------------------------------------------------------
602
603    /// Canonical SHA-256 computation over all tamper-meaningful fields.
604    ///
605    /// Field order and encoding are fixed — see [`AuditEntry::new`] for the
606    /// documented byte sequence. Lineage fields append only when `Some`;
607    /// when all lineage fields are `None`, output equals the pre-AAASM-934 hash exactly.
608    /// Redaction bytes append only when `redaction != Redaction::default()`;
609    /// the default value (empty findings + `None` payload) contributes 0 bytes,
610    /// so existing chains verify unchanged.
611    #[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        // Lineage — append only when present; None contributes 0 bytes.
632        // When all fields are None, hash equals pre-AAASM-934 output exactly.
633        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        // AAASM-2008 — org_id appended last in the lineage section so that
655        // entries with org_id=None hash identically to pre-AAASM-2008 output.
656        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        // Redaction — append only when non-default; empty Vec + None contributes 0 bytes
661        // so entries constructed via new() / new_with_lineage() hash exactly as before.
662        #[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// ---------------------------------------------------------------------------
685// Tool-dispatch helper (AAASM-1920 / Secret Injection)
686// ---------------------------------------------------------------------------
687
688/// Build an [`AuditEntry`] for a `dispatch_tool` call, carrying the
689/// **placeholder-form** args as the `payload`.
690///
691/// The caller passes `placeholder_args` as it was *received* from the agent
692/// — before any `${NAME}` token is resolved by
693/// `aa-gateway::secrets::resolver::resolve_placeholders`. The resolved
694/// credential value is forwarded to the tool sink separately, but it must
695/// never appear in `payload`. This helper centralises that contract so
696/// every dispatch_tool handler (HTTP, gRPC) emits the same shape.
697///
698/// Returns an entry tagged [`AuditEventType::ToolDispatched`].
699///
700/// Gated on `feature = "std"` because it relies on `serde_json::to_string`.
701#[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        // Malformed input is implausible for a `serde_json::Value` — this
712        // branch exists so the helper is total. A non-secret stand-in is
713        // recorded so the audit chain stays unbroken.
714        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
727// ---------------------------------------------------------------------------
728// Display
729// ---------------------------------------------------------------------------
730
731impl core::fmt::Display for AuditEntry {
732    /// Human-readable one-line representation suitable for log output.
733    ///
734    /// Format: `[seq=N ts=T agent=HEX session=HEX event=TypeName]`
735    ///
736    /// `payload` is omitted from `Display` — it may be arbitrarily large.
737    /// Use [`AuditEntry::payload`] to access the full payload string.
738    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// ---------------------------------------------------------------------------
752// AuditLogError
753// ---------------------------------------------------------------------------
754
755/// Error returned by [`AuditLog::push`] when an appended entry violates
756/// the log's monotonicity or hash-chain invariants.
757#[derive(Debug, Clone, PartialEq, Eq)]
758pub enum AuditLogError {
759    /// The entry's `seq` did not equal the log's expected next sequence number.
760    SequenceGap {
761        /// The sequence number the log expected.
762        expected: u64,
763        /// The sequence number the entry carried.
764        got: u64,
765    },
766    /// The entry's `previous_hash` did not match the `entry_hash` of the
767    /// last entry in the log (or the genesis zero-hash for the first entry).
768    HashChainBroken {
769        /// The `seq` of the entry that broke the chain.
770        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
787// ---------------------------------------------------------------------------
788// AuditLog
789// ---------------------------------------------------------------------------
790
791/// A session-scoped, append-only sequence of [`AuditEntry`] records that
792/// enforces monotonic sequence numbers and hash-chain continuity on every append.
793///
794/// ## Invariants
795///
796/// - Every entry's `seq` equals the previous entry's `seq + 1` (genesis: `seq = 0`).
797/// - Every entry's `previous_hash` equals the preceding entry's `entry_hash`
798///   (genesis entry uses `[0u8; 32]`).
799///
800/// Both invariants are checked by [`AuditLog::push`] at append time.
801/// [`AuditLog::verify_chain`] re-validates them across the entire stored log.
802pub struct AuditLog {
803    agent_id: AgentId,
804    session_id: SessionId,
805    entries: alloc::vec::Vec<AuditEntry>,
806    /// The `seq` value the next appended entry must carry.
807    next_seq: u64,
808    /// The `entry_hash` of the last appended entry; `[0u8; 32]` before any entry.
809    last_hash: [u8; 32],
810}
811
812impl AuditLog {
813    /// Create a new, empty [`AuditLog`] for the given agent and session.
814    ///
815    /// The log starts with `next_seq = 0` and `last_hash = [0u8; 32]` (the
816    /// genesis previous-hash sentinel).
817    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    /// Read-only view of all entries in append order.
828    pub fn entries(&self) -> &[AuditEntry] {
829        &self.entries
830    }
831
832    /// Number of entries currently stored in the log.
833    pub fn len(&self) -> usize {
834        self.entries.len()
835    }
836
837    /// Returns `true` if the log contains no entries.
838    pub fn is_empty(&self) -> bool {
839        self.entries.is_empty()
840    }
841
842    /// The agent identifier associated with this log.
843    pub fn agent_id(&self) -> AgentId {
844        self.agent_id
845    }
846
847    /// The session identifier associated with this log.
848    pub fn session_id(&self) -> SessionId {
849        self.session_id
850    }
851
852    /// Append a pre-built [`AuditEntry`] to the log, validating both invariants.
853    ///
854    /// ## Errors
855    ///
856    /// - [`AuditLogError::SequenceGap`] if `entry.seq() != self.next_seq`.
857    /// - [`AuditLogError::HashChainBroken`] if `entry.previous_hash() != &self.last_hash`.
858    ///
859    /// On error the log is not modified.
860    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    /// Build and append the next [`AuditEntry`] in one atomic step.
877    ///
878    /// `seq` and `previous_hash` are derived automatically from the log's
879    /// current state, eliminating the risk of caller-side sequencing errors.
880    ///
881    /// ## Parameters
882    ///
883    /// - `event_type` — category of the governance event.
884    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for
885    ///   `no_std` compatibility).
886    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
887    ///
888    /// Returns a reference to the newly appended entry.
889    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        // next_entry constructs the entry with the correct seq and previous_hash,
900        // so push() cannot fail here.
901        self.push(entry).expect("next_entry invariant: push cannot fail");
902        self.entries.last().expect("entry was just pushed")
903    }
904
905    /// Build and append the next [`AuditEntry`] with lineage fields in one atomic step.
906    ///
907    /// Equivalent to [`AuditLog::next_entry`] but attaches agent-topology metadata.
908    /// `seq` and `previous_hash` are derived automatically from the log's current state.
909    ///
910    /// ## Parameters
911    ///
912    /// - `event_type` — category of the governance event.
913    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for `no_std` compatibility).
914    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
915    /// - `lineage` — optional agent-topology fields; `Lineage::default()` produces the same hash
916    ///   as [`AuditLog::next_entry`] with the same base fields.
917    ///
918    /// Returns a reference to the newly appended entry.
919    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    /// Re-validate the entire log in O(n), checking both invariants for every entry.
942    ///
943    /// Returns `true` if:
944    /// - Every entry passes [`AuditEntry::verify_integrity`] (SHA-256 matches stored hash).
945    /// - Every entry's `seq` is exactly one greater than the previous entry's `seq`
946    ///   (first entry must have `seq = 0`).
947    /// - Every entry's `previous_hash` matches the preceding entry's `entry_hash`
948    ///   (first entry must have `previous_hash = [0u8; 32]`).
949    ///
950    /// Returns `true` for an empty log (vacuously valid).
951    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// ---------------------------------------------------------------------------
971// Tests
972// ---------------------------------------------------------------------------
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    // Shared test fixtures
979    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    // --- AuditEventType ---
996
997    #[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    // --- AuditEntry::new() and getters ---
1071
1072    #[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    // --- verify_integrity() ---
1106
1107    #[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        // SAFETY: deliberate tampering to test integrity detection.
1116        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        // SAFETY: deliberate tampering to test integrity detection.
1127        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        // SAFETY: deliberate tampering to test integrity detection.
1140        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        // SAFETY: deliberate tampering to test integrity detection.
1151        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    // --- Hash chain linkage ---
1159
1160    #[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    // --- Display ---
1212
1213    #[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        // AGENT_BYTES starts with 01 02 03 04
1229        assert!(s.contains("agent=01020304"));
1230        // SESSION_BYTES starts with 11 12 13 14
1231        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        // For each Sandbox* lifecycle variant introduced under AAASM-1965,
1244        // assert that AuditEntry's Display surfaces the variant's `as_str()`
1245        // label verbatim. Auditors grep the JSONL log by these tokens.
1246        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    // --- AuditLog helpers ---
1278
1279    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    // --- AuditLog::push() ---
1296
1297    #[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); // expected seq=0
1309        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); // duplicate seq=0
1322        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]; // not equal to e0.entry_hash()
1334        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    // --- AuditLog::next_entry() ---
1371
1372    #[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        // First entry via next_entry
1432        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        // Second entry via manual push with correct seq and previous_hash
1440        let e1 = make_valid_entry(1, hash0);
1441        log.push(e1).unwrap();
1442
1443        // Third entry via next_entry — should pick up seq=2 and hash1
1444        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    // --- AuditLog::verify_chain() ---
1471
1472    #[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        // Tamper the seq of the first entry.
1505        // SAFETY: deliberate tampering to test verify_chain detection.
1506        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        // Tamper the payload of the second entry — breaks its verify_integrity().
1529        // SAFETY: deliberate tampering to test verify_chain detection.
1530        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        // Tamper previous_hash of the second entry — breaks chain linkage check.
1554        // SAFETY: deliberate tampering to test verify_chain detection.
1555        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    // --- audit_entry_for_tool_dispatch (AAASM-1920 Secret Injection) ---
1564
1565    #[test]
1566    fn tool_dispatch_helper_emits_placeholder_form_payload() {
1567        // Synthetic — fabricated for this test only.
1568        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        // Payload carries the placeholder-form, not the resolved value.
1584        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    /// Synthetic AWS access key from AWS public documentation. Not a real credential.
1791    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        // Decision metadata only — no raw secret bytes in the audit payload itself.
1811        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        // Primary security invariant: the raw secret bytes never appear in the
1827        // serialized AuditEntry — neither in `payload`, nor in `credential_findings`,
1828        // nor in `redacted_payload`.
1829        assert!(
1830            !serialized.contains(FAKE_AWS_ACCESS_KEY),
1831            "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
1832        );
1833
1834        // Secondary sanity check: the redaction label IS present, proving findings were attached.
1835        assert!(
1836            serialized.contains("[REDACTED:AwsAccessKey]"),
1837            "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
1838        );
1839
1840        // Tamper-evidence holds: the entry validates against its recorded hash.
1841        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        // new() and new_with_lineage_and_redaction(_, _, ..., Redaction::default())
1850        // must produce identical entry_hash for the same base fields. This guarantees
1851        // the existing audit chain on disk continues to verify after this PR lands.
1852        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}