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))]
25pub enum AuditEventType {
26    /// A tool call was intercepted by the governance layer before execution.
27    ToolCallIntercepted = 0,
28    /// An evaluated action violated an active policy rule.
29    PolicyViolation = 1,
30    /// A credential or secret present in tool arguments was blocked.
31    CredentialLeakBlocked = 2,
32    /// Human approval was requested before the action could proceed.
33    ApprovalRequested = 3,
34    /// A pending human approval request was granted.
35    ApprovalGranted = 4,
36    /// A pending human approval request was denied.
37    ApprovalDenied = 5,
38    /// The session budget is approaching its configured limit.
39    BudgetLimitApproached = 6,
40    /// The session budget has been exhausted; further actions are blocked.
41    BudgetLimitExceeded = 7,
42    /// A pending human approval request expired before a decision was made.
43    ApprovalTimedOut = 8,
44    /// An approval request was routed to a team-specific approver queue.
45    ApprovalRouted = 9,
46    /// An approval request was escalated after the initial approver did not respond.
47    ApprovalEscalated = 10,
48    /// An agent was force-deregistered by the gateway because it exceeded its configured maximum age.
49    AgentForceDeregistered = 11,
50    /// An inter-team message was blocked because the target channel is not permitted by policy.
51    MessageBlocked = 12,
52    /// A `dispatch_tool` call was processed by the gateway: the agent's
53    /// placeholder-form args were resolved via the `SecretsStore` and
54    /// forwarded to the tool sink. The audit `payload` carries the
55    /// **placeholder-form** args — the resolved credential value is never
56    /// recorded. (AAASM-1920 / Secret Injection.)
57    ToolDispatched = 13,
58    /// An agent-to-agent (A2A) call was intercepted. The audit `payload`
59    /// carries both `caller_agent_id` (the originating agent) and
60    /// `callee_agent_id` (the agent performing the action), so reviewers
61    /// can reconstruct cross-agent delegation graphs even when the call
62    /// was allowed. Emitted only when the request's `caller_agent_id` is
63    /// populated and differs from `agent_id`. (AAASM-1944 / Zero-trust A2A.)
64    A2ACallIntercepted = 14,
65    /// An impersonation attempt was rejected: the request claimed an
66    /// `agent_id` whose registered `credential_token` does not match the
67    /// token supplied. The audit `payload` carries `claimed_agent_id` and
68    /// the agent whose `credential_token` was actually presented (when
69    /// resolvable). The action is denied before policy evaluation runs.
70    /// (AAASM-1944 / Zero-trust A2A.)
71    A2AImpersonationAttempted = 15,
72    /// A sandboxed (WASM/WASI) tool invocation has begun. Emitted by
73    /// `aa-proxy` immediately before handing the parsed `tools/call`
74    /// envelope to the `aa-sandbox` runtime for execution. Marks the
75    /// start of the lifecycle terminated by [`SandboxTerminated`],
76    /// [`SandboxFilesystemBlocked`], [`SandboxCpuTimeout`], or
77    /// [`SandboxOomKilled`]. (AAASM-1965 / F116 ST-W.)
78    ///
79    /// [`SandboxTerminated`]: Self::SandboxTerminated
80    /// [`SandboxFilesystemBlocked`]: Self::SandboxFilesystemBlocked
81    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
82    /// [`SandboxOomKilled`]: Self::SandboxOomKilled
83    SandboxStarted = 16,
84    /// A sandboxed tool attempted to read or write outside the WASI
85    /// preopened-dir allowlist. Surfaced by `aa-sandbox::runtime` when
86    /// `path_open` (or another `fd_*` host call) returns `EACCES`
87    /// because the requested path is not under any directory passed to
88    /// `WasiCtxBuilder::preopened_dir`. (AAASM-1965 / F116 ST-W,
89    /// Scenario 1 — filesystem isolation.)
90    SandboxFilesystemBlocked = 17,
91    /// A sandboxed tool was killed because its wasmtime instruction-fuel
92    /// budget (or wall-clock guard) was exhausted. Surfaced by
93    /// `aa-sandbox::runtime` when `Store::set_fuel` drains to zero or
94    /// the wall-clock watcher fires, mapping to
95    /// `SandboxError::CpuTimeout` / `SandboxError::WallClockTimeout`.
96    /// (AAASM-1965 / F116 ST-W, Scenario 2 — runaway-loop kill.)
97    SandboxCpuTimeout = 18,
98    /// A sandboxed tool was killed because wasmtime's `Store::limiter`
99    /// rejected a memory-growth request that would exceed the
100    /// configured `memory_pages` ceiling. Surfaced by
101    /// `aa-sandbox::runtime` mapping to `SandboxError::MemoryExhausted`.
102    /// Distinguished from [`SandboxCpuTimeout`] so audit consumers can
103    /// tell whether the host pressure was instruction-fuel or memory
104    /// pages. (AAASM-1965 / F116 ST-W, Scenario 2 — memory-bomb kill.)
105    ///
106    /// [`SandboxCpuTimeout`]: Self::SandboxCpuTimeout
107    SandboxOomKilled = 19,
108    /// A sandboxed tool invocation completed without being killed by
109    /// any isolation primitive. Emitted by `aa-sandbox::runtime`
110    /// regardless of whether the tool returned a logical success or a
111    /// tool-defined error — this variant marks lifecycle completion,
112    /// not outcome semantics. (AAASM-1965 / F116 ST-W.)
113    SandboxTerminated = 20,
114}
115
116impl AuditEventType {
117    /// Returns the string label used in [`Display`] output and log messages.
118    ///
119    /// [`Display`]: core::fmt::Display
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            Self::ToolCallIntercepted => "ToolCallIntercepted",
123            Self::PolicyViolation => "PolicyViolation",
124            Self::CredentialLeakBlocked => "CredentialLeakBlocked",
125            Self::ApprovalRequested => "ApprovalRequested",
126            Self::ApprovalGranted => "ApprovalGranted",
127            Self::ApprovalDenied => "ApprovalDenied",
128            Self::BudgetLimitApproached => "BudgetLimitApproached",
129            Self::BudgetLimitExceeded => "BudgetLimitExceeded",
130            Self::ApprovalTimedOut => "ApprovalTimedOut",
131            Self::ApprovalRouted => "ApprovalRouted",
132            Self::ApprovalEscalated => "ApprovalEscalated",
133            Self::AgentForceDeregistered => "AgentForceDeregistered",
134            Self::MessageBlocked => "MessageBlocked",
135            Self::ToolDispatched => "ToolDispatched",
136            Self::A2ACallIntercepted => "A2ACallIntercepted",
137            Self::A2AImpersonationAttempted => "A2AImpersonationAttempted",
138            Self::SandboxStarted => "SandboxStarted",
139            Self::SandboxFilesystemBlocked => "SandboxFilesystemBlocked",
140            Self::SandboxCpuTimeout => "SandboxCpuTimeout",
141            Self::SandboxOomKilled => "SandboxOomKilled",
142            Self::SandboxTerminated => "SandboxTerminated",
143        }
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Lineage
149// ---------------------------------------------------------------------------
150
151/// Optional agent-topology fields attached to an [`AuditEntry`].
152///
153/// All fields are `None` for entries emitted without an `AgentContext`
154/// (legacy path). `Lineage::default()` passed to
155/// [`AuditEntry::new_with_lineage`] produces a hash identical to
156/// [`AuditEntry::new`] with the same base fields.
157#[derive(Debug, Clone, PartialEq, Default)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub struct Lineage {
160    /// Root agent identifier at the top of the delegation chain.
161    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
162    pub root_agent_id: Option<AgentId>,
163    /// Identifier of the agent that directly spawned this agent.
164    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
165    pub parent_agent_id: Option<AgentId>,
166    /// Team identifier associated with the agent that produced the entry.
167    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
168    pub team_id: Option<String>,
169    /// AAASM-2008 — organization identifier of the agent that produced the
170    /// entry. Mirrors `team_id` but at the multi-tenancy tier so audit logs,
171    /// compliance exports, and operator queries can filter / scope by Org.
172    /// Present when the gateway can resolve `org_id` from the agent's
173    /// registration metadata; `None` for entries emitted without an
174    /// `AgentContext` or registered without org metadata.
175    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
176    pub org_id: Option<String>,
177    /// Human-readable reason the action was delegated to this agent.
178    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
179    pub delegation_reason: Option<String>,
180    /// Name of the tool or framework that spawned this agent (e.g. `"langgraph"`).
181    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
182    pub spawned_by_tool: Option<String>,
183    /// Delegation depth from the root agent (`0` = root, `1` = first delegate, …).
184    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
185    pub depth: Option<u32>,
186}
187
188// ---------------------------------------------------------------------------
189// Redaction
190// ---------------------------------------------------------------------------
191
192/// Optional credential-redaction artefacts attached to an [`AuditEntry`].
193///
194/// Populated when the gateway's policy engine ran the credential scanner
195/// (Stage 6 of `PolicyEngine::evaluate`) and produced at least one finding.
196/// Both fields default to empty / `None`, matching the legacy code path that
197/// constructs entries without scanner output. `Redaction::default()` passed
198/// to [`AuditEntry::new_with_lineage_and_redaction`] produces a hash
199/// identical to [`AuditEntry::new_with_lineage`] with the same base fields,
200/// preserving backward compatibility.
201///
202/// ## Security invariant
203///
204/// Neither field stores the raw secret value. `credential_findings` holds
205/// only the [`CredentialKind`](crate::scanner::CredentialKind), byte offset,
206/// and the `[REDACTED:<kind>]` label (`CredentialFinding`'s `end` field is
207/// `#[serde(skip)]`). `redacted_payload` holds the sanitised payload returned
208/// by `ScanResult::redact` where every match has been replaced with its
209/// `[REDACTED:<kind>]` label.
210///
211/// ## Feature gating
212///
213/// Gated on `std` because [`CredentialFinding`](crate::scanner::CredentialFinding)
214/// lives in the `std`-only `scanner` module. `no_std + alloc` builds compile
215/// `AuditEntry` without redaction support.
216#[cfg(feature = "std")]
217#[derive(Debug, Clone, PartialEq, Eq, Default)]
218#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
219pub struct Redaction {
220    /// All credential / PII findings detected by the scanner. Empty when the
221    /// scanner found nothing.
222    pub credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
223    /// The redacted version of the action payload (raw secret bytes replaced
224    /// with `[REDACTED:<kind>]` labels). `None` when no findings were produced.
225    pub redacted_payload: Option<alloc::string::String>,
226}
227
228// ---------------------------------------------------------------------------
229// AuditEntry
230// ---------------------------------------------------------------------------
231
232/// An immutable, hash-chained record of a single governance event.
233///
234/// ## Immutability
235///
236/// All fields are private. The only way to create an [`AuditEntry`] is via
237/// [`AuditEntry::new`]. There are no mutation methods.
238///
239/// ## Hash chain
240///
241/// `entry_hash` is a SHA-256 digest computed over all tamper-meaningful fields
242/// in a canonical byte order (see [`AuditEntry::new`] for the full sequence).
243/// Each entry commits to `previous_hash`, linking entries into a tamper-evident
244/// chain. The genesis entry uses `[0u8; 32]` as `previous_hash`.
245///
246/// ## Tamper detection
247///
248/// [`AuditEntry::verify_integrity`] re-computes the hash from the stored fields
249/// and compares it to the stored `entry_hash`. Any field alteration — including
250/// via `unsafe` code — will cause the re-computed hash to diverge.
251#[derive(Debug, Clone, PartialEq, Eq)]
252#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
253pub struct AuditEntry {
254    seq: u64,
255    timestamp_ns: u64,
256    event_type: AuditEventType,
257    agent_id: AgentId,
258    session_id: SessionId,
259    payload: String,
260    previous_hash: [u8; 32],
261    entry_hash: [u8; 32],
262    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
263    root_agent_id: Option<AgentId>,
264    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
265    parent_agent_id: Option<AgentId>,
266    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
267    team_id: Option<String>,
268    /// AAASM-2008 — see [`Lineage::org_id`].
269    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
270    org_id: Option<String>,
271    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
272    delegation_reason: Option<String>,
273    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
274    spawned_by_tool: Option<String>,
275    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
276    depth: Option<u32>,
277    #[cfg(feature = "std")]
278    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default))]
279    credential_findings: alloc::vec::Vec<crate::scanner::CredentialFinding>,
280    #[cfg(feature = "std")]
281    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
282    redacted_payload: Option<String>,
283}
284
285impl AuditEntry {
286    // -----------------------------------------------------------------------
287    // Constructor
288    // -----------------------------------------------------------------------
289
290    /// Create a new [`AuditEntry`], computing `entry_hash` over all fields.
291    ///
292    /// ## Parameters
293    ///
294    /// - `seq` — monotonic counter within the session; genesis entry is `0`.
295    /// - `timestamp_ns` — nanoseconds since the Unix epoch (caller-supplied;
296    ///   use `Timestamp::from(SystemTime::now()).as_nanos()` in `std` environments).
297    /// - `event_type` — category of the governance event.
298    /// - `agent_id` — identifier of the agent that produced the event.
299    /// - `session_id` — identifier of the specific agent run.
300    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
301    /// - `previous_hash` — `entry_hash` of the preceding entry;
302    ///   `[0u8; 32]` for the genesis entry.
303    ///
304    /// ## Canonical hash input (84 fixed bytes + variable payload)
305    ///
306    /// ```text
307    /// SHA-256(
308    ///     seq.to_be_bytes()                  //  8 bytes
309    ///     || timestamp_ns.to_be_bytes()      //  8 bytes
310    ///     || (event_type as u32).to_be_bytes() // 4 bytes
311    ///     || agent_id.as_bytes()             // 16 bytes
312    ///     || session_id.as_bytes()           // 16 bytes
313    ///     || previous_hash                   // 32 bytes
314    ///     || payload.as_bytes()              // variable
315    /// )
316    /// ```
317    pub fn new(
318        seq: u64,
319        timestamp_ns: u64,
320        event_type: AuditEventType,
321        agent_id: AgentId,
322        session_id: SessionId,
323        payload: String,
324        previous_hash: [u8; 32],
325    ) -> Self {
326        let entry_hash = Self::compute_hash(
327            seq,
328            timestamp_ns,
329            &event_type,
330            &agent_id,
331            &session_id,
332            &previous_hash,
333            &payload,
334            &Lineage::default(),
335            #[cfg(feature = "std")]
336            &Redaction::default(),
337        );
338        Self {
339            seq,
340            timestamp_ns,
341            event_type,
342            agent_id,
343            session_id,
344            payload,
345            previous_hash,
346            entry_hash,
347            root_agent_id: None,
348            parent_agent_id: None,
349            team_id: None,
350            org_id: None,
351            delegation_reason: None,
352            spawned_by_tool: None,
353            depth: None,
354            #[cfg(feature = "std")]
355            credential_findings: alloc::vec::Vec::new(),
356            #[cfg(feature = "std")]
357            redacted_payload: None,
358        }
359    }
360
361    /// Create a new [`AuditEntry`] with optional lineage fields, computing `entry_hash`
362    /// over all fields including the lineage data.
363    ///
364    /// When `lineage` is `Lineage::default()` (all fields `None`), the resulting
365    /// `entry_hash` is identical to that produced by [`AuditEntry::new`] with the
366    /// same base fields, preserving backward compatibility.
367    #[allow(clippy::too_many_arguments)]
368    pub fn new_with_lineage(
369        seq: u64,
370        timestamp_ns: u64,
371        event_type: AuditEventType,
372        agent_id: AgentId,
373        session_id: SessionId,
374        payload: String,
375        previous_hash: [u8; 32],
376        lineage: Lineage,
377    ) -> Self {
378        let entry_hash = Self::compute_hash(
379            seq,
380            timestamp_ns,
381            &event_type,
382            &agent_id,
383            &session_id,
384            &previous_hash,
385            &payload,
386            &lineage,
387            #[cfg(feature = "std")]
388            &Redaction::default(),
389        );
390        Self {
391            seq,
392            timestamp_ns,
393            event_type,
394            agent_id,
395            session_id,
396            payload,
397            previous_hash,
398            entry_hash,
399            root_agent_id: lineage.root_agent_id,
400            parent_agent_id: lineage.parent_agent_id,
401            team_id: lineage.team_id,
402            org_id: lineage.org_id,
403            delegation_reason: lineage.delegation_reason,
404            spawned_by_tool: lineage.spawned_by_tool,
405            depth: lineage.depth,
406            #[cfg(feature = "std")]
407            credential_findings: alloc::vec::Vec::new(),
408            #[cfg(feature = "std")]
409            redacted_payload: None,
410        }
411    }
412
413    /// Create a new [`AuditEntry`] carrying both lineage data and credential
414    /// scanner output, computing `entry_hash` over all tamper-meaningful fields.
415    ///
416    /// When `redaction == Redaction::default()` (empty findings + `None` payload),
417    /// the resulting `entry_hash` is identical to [`AuditEntry::new_with_lineage`]
418    /// with the same base fields — so callers that don't have scanner data can
419    /// continue using the legacy constructors without any chain divergence.
420    ///
421    /// Gated on `std` because [`Redaction`] holds
422    /// [`CredentialFinding`](crate::scanner::CredentialFinding) values, which
423    /// live in the `std`-only `scanner` module.
424    #[cfg(feature = "std")]
425    #[allow(clippy::too_many_arguments)]
426    pub fn new_with_lineage_and_redaction(
427        seq: u64,
428        timestamp_ns: u64,
429        event_type: AuditEventType,
430        agent_id: AgentId,
431        session_id: SessionId,
432        payload: String,
433        previous_hash: [u8; 32],
434        lineage: Lineage,
435        redaction: Redaction,
436    ) -> Self {
437        let entry_hash = Self::compute_hash(
438            seq,
439            timestamp_ns,
440            &event_type,
441            &agent_id,
442            &session_id,
443            &previous_hash,
444            &payload,
445            &lineage,
446            &redaction,
447        );
448        Self {
449            seq,
450            timestamp_ns,
451            event_type,
452            agent_id,
453            session_id,
454            payload,
455            previous_hash,
456            entry_hash,
457            root_agent_id: lineage.root_agent_id,
458            parent_agent_id: lineage.parent_agent_id,
459            team_id: lineage.team_id,
460            org_id: lineage.org_id,
461            delegation_reason: lineage.delegation_reason,
462            spawned_by_tool: lineage.spawned_by_tool,
463            depth: lineage.depth,
464            credential_findings: redaction.credential_findings,
465            redacted_payload: redaction.redacted_payload,
466        }
467    }
468
469    // -----------------------------------------------------------------------
470    // Getters
471    // -----------------------------------------------------------------------
472
473    /// Monotonic sequence counter within the session.
474    #[inline]
475    pub fn seq(&self) -> u64 {
476        self.seq
477    }
478
479    /// Nanoseconds since the Unix epoch at the time the entry was created.
480    #[inline]
481    pub fn timestamp_ns(&self) -> u64 {
482        self.timestamp_ns
483    }
484
485    /// Category of the governance event.
486    #[inline]
487    pub fn event_type(&self) -> AuditEventType {
488        self.event_type
489    }
490
491    /// Identifier of the agent that produced this entry.
492    #[inline]
493    pub fn agent_id(&self) -> AgentId {
494        self.agent_id
495    }
496
497    /// Identifier of the specific agent run (session) that produced this entry.
498    #[inline]
499    pub fn session_id(&self) -> SessionId {
500        self.session_id
501    }
502
503    /// Pre-serialized UTF-8 payload (JSON in practice).
504    #[inline]
505    pub fn payload(&self) -> &str {
506        &self.payload
507    }
508
509    /// SHA-256 hash of the preceding entry; `[0u8; 32]` for the genesis entry.
510    #[inline]
511    pub fn previous_hash(&self) -> &[u8; 32] {
512        &self.previous_hash
513    }
514
515    /// SHA-256 hash computed over all tamper-meaningful fields at construction.
516    #[inline]
517    pub fn entry_hash(&self) -> &[u8; 32] {
518        &self.entry_hash
519    }
520
521    /// Root agent identifier in the delegation chain, if present.
522    #[inline]
523    pub fn root_agent_id(&self) -> Option<AgentId> {
524        self.root_agent_id
525    }
526
527    /// Parent agent identifier that directly spawned this agent, if present.
528    #[inline]
529    pub fn parent_agent_id(&self) -> Option<AgentId> {
530        self.parent_agent_id
531    }
532
533    /// Team identifier associated with the agent, if present.
534    #[inline]
535    pub fn team_id(&self) -> Option<&str> {
536        self.team_id.as_deref()
537    }
538
539    /// AAASM-2008 — organization identifier associated with the agent, if
540    /// present. Mirrors [`Self::team_id`] at the multi-tenancy tier.
541    #[inline]
542    pub fn org_id(&self) -> Option<&str> {
543        self.org_id.as_deref()
544    }
545
546    /// Reason this agent was delegated the action, if present.
547    #[inline]
548    pub fn delegation_reason(&self) -> Option<&str> {
549        self.delegation_reason.as_deref()
550    }
551
552    /// Name of the tool that spawned this agent, if present.
553    #[inline]
554    pub fn spawned_by_tool(&self) -> Option<&str> {
555        self.spawned_by_tool.as_deref()
556    }
557
558    /// Delegation depth from the root agent, if present.
559    #[inline]
560    pub fn depth(&self) -> Option<u32> {
561        self.depth
562    }
563
564    /// Credential / PII findings detected by the policy engine's scanner pass.
565    ///
566    /// Empty when the scan was clean (or when the entry was constructed via a
567    /// pre-redaction-aware code path). Each [`CredentialFinding`](crate::scanner::CredentialFinding)
568    /// stores only the kind, byte offset, and `[REDACTED:<kind>]` label —
569    /// never the raw secret bytes.
570    #[cfg(feature = "std")]
571    #[inline]
572    pub fn credential_findings(&self) -> &[crate::scanner::CredentialFinding] {
573        &self.credential_findings
574    }
575
576    /// Redacted version of the action payload, if the scanner produced findings.
577    ///
578    /// `None` when the scan was clean. When `Some`, every detected secret has
579    /// been replaced with its `[REDACTED:<kind>]` label so the audit trail
580    /// itself never leaks the raw secret.
581    #[cfg(feature = "std")]
582    #[inline]
583    pub fn redacted_payload(&self) -> Option<&str> {
584        self.redacted_payload.as_deref()
585    }
586
587    // -----------------------------------------------------------------------
588    // Integrity
589    // -----------------------------------------------------------------------
590
591    /// Returns `true` if the stored `entry_hash` matches a fresh re-computation
592    /// over the stored fields.
593    ///
594    /// Returns `false` if any field has been altered after construction — including
595    /// via `unsafe` code.
596    pub fn verify_integrity(&self) -> bool {
597        let lineage = Lineage {
598            root_agent_id: self.root_agent_id,
599            parent_agent_id: self.parent_agent_id,
600            team_id: self.team_id.clone(),
601            org_id: self.org_id.clone(),
602            delegation_reason: self.delegation_reason.clone(),
603            spawned_by_tool: self.spawned_by_tool.clone(),
604            depth: self.depth,
605        };
606        #[cfg(feature = "std")]
607        let redaction = Redaction {
608            credential_findings: self.credential_findings.clone(),
609            redacted_payload: self.redacted_payload.clone(),
610        };
611        let expected = Self::compute_hash(
612            self.seq,
613            self.timestamp_ns,
614            &self.event_type,
615            &self.agent_id,
616            &self.session_id,
617            &self.previous_hash,
618            &self.payload,
619            &lineage,
620            #[cfg(feature = "std")]
621            &redaction,
622        );
623        expected == self.entry_hash
624    }
625
626    // -----------------------------------------------------------------------
627    // Private helpers
628    // -----------------------------------------------------------------------
629
630    /// Canonical SHA-256 computation over all tamper-meaningful fields.
631    ///
632    /// Field order and encoding are fixed — see [`AuditEntry::new`] for the
633    /// documented byte sequence. Lineage fields append only when `Some`;
634    /// when all lineage fields are `None`, output equals the pre-AAASM-934 hash exactly.
635    /// Redaction bytes append only when `redaction != Redaction::default()`;
636    /// the default value (empty findings + `None` payload) contributes 0 bytes,
637    /// so existing chains verify unchanged.
638    #[allow(clippy::too_many_arguments)]
639    fn compute_hash(
640        seq: u64,
641        timestamp_ns: u64,
642        event_type: &AuditEventType,
643        agent_id: &AgentId,
644        session_id: &SessionId,
645        previous_hash: &[u8; 32],
646        payload: &str,
647        lineage: &Lineage,
648        #[cfg(feature = "std")] redaction: &Redaction,
649    ) -> [u8; 32] {
650        let mut hasher = Sha256::new();
651        hasher.update(seq.to_be_bytes());
652        hasher.update(timestamp_ns.to_be_bytes());
653        hasher.update((*event_type as u32).to_be_bytes());
654        hasher.update(agent_id.as_bytes());
655        hasher.update(session_id.as_bytes());
656        hasher.update(previous_hash);
657        hasher.update(payload.as_bytes());
658        // Lineage — append only when present; None contributes 0 bytes.
659        // When all fields are None, hash equals pre-AAASM-934 output exactly.
660        if let Some(id) = &lineage.root_agent_id {
661            hasher.update(id.as_bytes());
662        }
663        if let Some(id) = &lineage.parent_agent_id {
664            hasher.update(id.as_bytes());
665        }
666        if let Some(s) = &lineage.team_id {
667            hasher.update((s.len() as u32).to_be_bytes());
668            hasher.update(s.as_bytes());
669        }
670        if let Some(s) = &lineage.delegation_reason {
671            hasher.update((s.len() as u32).to_be_bytes());
672            hasher.update(s.as_bytes());
673        }
674        if let Some(s) = &lineage.spawned_by_tool {
675            hasher.update((s.len() as u32).to_be_bytes());
676            hasher.update(s.as_bytes());
677        }
678        if let Some(d) = lineage.depth {
679            hasher.update(d.to_be_bytes());
680        }
681        // AAASM-2008 — org_id appended last in the lineage section so that
682        // entries with org_id=None hash identically to pre-AAASM-2008 output.
683        if let Some(s) = &lineage.org_id {
684            hasher.update((s.len() as u32).to_be_bytes());
685            hasher.update(s.as_bytes());
686        }
687        // Redaction — append only when non-default; empty Vec + None contributes 0 bytes
688        // so entries constructed via new() / new_with_lineage() hash exactly as before.
689        #[cfg(feature = "std")]
690        {
691            if !redaction.credential_findings.is_empty() || redaction.redacted_payload.is_some() {
692                hasher.update((redaction.credential_findings.len() as u32).to_be_bytes());
693                for finding in &redaction.credential_findings {
694                    hasher.update((finding.offset as u64).to_be_bytes());
695                    hasher.update((finding.matched.len() as u32).to_be_bytes());
696                    hasher.update(finding.matched.as_bytes());
697                }
698                if let Some(s) = &redaction.redacted_payload {
699                    hasher.update([1u8]);
700                    hasher.update((s.len() as u32).to_be_bytes());
701                    hasher.update(s.as_bytes());
702                } else {
703                    hasher.update([0u8]);
704                }
705            }
706        }
707        hasher.finalize().into()
708    }
709}
710
711// ---------------------------------------------------------------------------
712// Tool-dispatch helper (AAASM-1920 / Secret Injection)
713// ---------------------------------------------------------------------------
714
715/// Build an [`AuditEntry`] for a `dispatch_tool` call, carrying the
716/// **placeholder-form** args as the `payload`.
717///
718/// The caller passes `placeholder_args` as it was *received* from the agent
719/// — before any `${NAME}` token is resolved by
720/// `aa-gateway::secrets::resolver::resolve_placeholders`. The resolved
721/// credential value is forwarded to the tool sink separately, but it must
722/// never appear in `payload`. This helper centralises that contract so
723/// every dispatch_tool handler (HTTP, gRPC) emits the same shape.
724///
725/// Returns an entry tagged [`AuditEventType::ToolDispatched`].
726///
727/// Gated on `feature = "std"` because it relies on `serde_json::to_string`.
728#[cfg(feature = "std")]
729pub fn audit_entry_for_tool_dispatch(
730    seq: u64,
731    timestamp_ns: u64,
732    agent_id: AgentId,
733    session_id: SessionId,
734    placeholder_args: &serde_json::Value,
735    previous_hash: [u8; 32],
736) -> AuditEntry {
737    let payload = serde_json::to_string(placeholder_args).unwrap_or_else(|_| {
738        // Malformed input is implausible for a `serde_json::Value` — this
739        // branch exists so the helper is total. A non-secret stand-in is
740        // recorded so the audit chain stays unbroken.
741        String::from("{\"error\":\"failed to serialize placeholder args\"}")
742    });
743    AuditEntry::new(
744        seq,
745        timestamp_ns,
746        AuditEventType::ToolDispatched,
747        agent_id,
748        session_id,
749        payload,
750        previous_hash,
751    )
752}
753
754// ---------------------------------------------------------------------------
755// Display
756// ---------------------------------------------------------------------------
757
758impl core::fmt::Display for AuditEntry {
759    /// Human-readable one-line representation suitable for log output.
760    ///
761    /// Format: `[seq=N ts=T agent=HEX session=HEX event=TypeName]`
762    ///
763    /// `payload` is omitted from `Display` — it may be arbitrarily large.
764    /// Use [`AuditEntry::payload`] to access the full payload string.
765    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
766        write!(f, "[seq={} ts={} agent=", self.seq, self.timestamp_ns)?;
767        for b in self.agent_id.as_bytes() {
768            write!(f, "{:02x}", b)?;
769        }
770        write!(f, " session=")?;
771        for b in self.session_id.as_bytes() {
772            write!(f, "{:02x}", b)?;
773        }
774        write!(f, " event={}]", self.event_type.as_str())
775    }
776}
777
778// ---------------------------------------------------------------------------
779// AuditLogError
780// ---------------------------------------------------------------------------
781
782/// Error returned by [`AuditLog::push`] when an appended entry violates
783/// the log's monotonicity or hash-chain invariants.
784#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum AuditLogError {
786    /// The entry's `seq` did not equal the log's expected next sequence number.
787    SequenceGap {
788        /// The sequence number the log expected.
789        expected: u64,
790        /// The sequence number the entry carried.
791        got: u64,
792    },
793    /// The entry's `previous_hash` did not match the `entry_hash` of the
794    /// last entry in the log (or the genesis zero-hash for the first entry).
795    HashChainBroken {
796        /// The `seq` of the entry that broke the chain.
797        at_seq: u64,
798    },
799}
800
801impl core::fmt::Display for AuditLogError {
802    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
803        match self {
804            Self::SequenceGap { expected, got } => {
805                write!(f, "audit log sequence gap: expected seq={expected}, got seq={got}")
806            }
807            Self::HashChainBroken { at_seq } => {
808                write!(f, "audit log hash chain broken at seq={at_seq}")
809            }
810        }
811    }
812}
813
814// ---------------------------------------------------------------------------
815// AuditLog
816// ---------------------------------------------------------------------------
817
818/// A session-scoped, append-only sequence of [`AuditEntry`] records that
819/// enforces monotonic sequence numbers and hash-chain continuity on every append.
820///
821/// ## Invariants
822///
823/// - Every entry's `seq` equals the previous entry's `seq + 1` (genesis: `seq = 0`).
824/// - Every entry's `previous_hash` equals the preceding entry's `entry_hash`
825///   (genesis entry uses `[0u8; 32]`).
826///
827/// Both invariants are checked by [`AuditLog::push`] at append time.
828/// [`AuditLog::verify_chain`] re-validates them across the entire stored log.
829pub struct AuditLog {
830    agent_id: AgentId,
831    session_id: SessionId,
832    entries: alloc::vec::Vec<AuditEntry>,
833    /// The `seq` value the next appended entry must carry.
834    next_seq: u64,
835    /// The `entry_hash` of the last appended entry; `[0u8; 32]` before any entry.
836    last_hash: [u8; 32],
837}
838
839impl AuditLog {
840    /// Create a new, empty [`AuditLog`] for the given agent and session.
841    ///
842    /// The log starts with `next_seq = 0` and `last_hash = [0u8; 32]` (the
843    /// genesis previous-hash sentinel).
844    pub fn new(agent_id: AgentId, session_id: SessionId) -> Self {
845        Self {
846            agent_id,
847            session_id,
848            entries: alloc::vec::Vec::new(),
849            next_seq: 0,
850            last_hash: [0u8; 32],
851        }
852    }
853
854    /// Read-only view of all entries in append order.
855    pub fn entries(&self) -> &[AuditEntry] {
856        &self.entries
857    }
858
859    /// Number of entries currently stored in the log.
860    pub fn len(&self) -> usize {
861        self.entries.len()
862    }
863
864    /// Returns `true` if the log contains no entries.
865    pub fn is_empty(&self) -> bool {
866        self.entries.is_empty()
867    }
868
869    /// The agent identifier associated with this log.
870    pub fn agent_id(&self) -> AgentId {
871        self.agent_id
872    }
873
874    /// The session identifier associated with this log.
875    pub fn session_id(&self) -> SessionId {
876        self.session_id
877    }
878
879    /// Append a pre-built [`AuditEntry`] to the log, validating both invariants.
880    ///
881    /// ## Errors
882    ///
883    /// - [`AuditLogError::SequenceGap`] if `entry.seq() != self.next_seq`.
884    /// - [`AuditLogError::HashChainBroken`] if `entry.previous_hash() != &self.last_hash`.
885    ///
886    /// On error the log is not modified.
887    pub fn push(&mut self, entry: AuditEntry) -> Result<(), AuditLogError> {
888        if entry.seq() != self.next_seq {
889            return Err(AuditLogError::SequenceGap {
890                expected: self.next_seq,
891                got: entry.seq(),
892            });
893        }
894        if entry.previous_hash() != &self.last_hash {
895            return Err(AuditLogError::HashChainBroken { at_seq: entry.seq() });
896        }
897        self.last_hash = *entry.entry_hash();
898        self.next_seq += 1;
899        self.entries.push(entry);
900        Ok(())
901    }
902
903    /// Build and append the next [`AuditEntry`] in one atomic step.
904    ///
905    /// `seq` and `previous_hash` are derived automatically from the log's
906    /// current state, eliminating the risk of caller-side sequencing errors.
907    ///
908    /// ## Parameters
909    ///
910    /// - `event_type` — category of the governance event.
911    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for
912    ///   `no_std` compatibility).
913    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
914    ///
915    /// Returns a reference to the newly appended entry.
916    pub fn next_entry(&mut self, event_type: AuditEventType, timestamp_ns: u64, payload: String) -> &AuditEntry {
917        let entry = AuditEntry::new(
918            self.next_seq,
919            timestamp_ns,
920            event_type,
921            self.agent_id,
922            self.session_id,
923            payload,
924            self.last_hash,
925        );
926        // next_entry constructs the entry with the correct seq and previous_hash,
927        // so push() cannot fail here.
928        self.push(entry).expect("next_entry invariant: push cannot fail");
929        self.entries.last().expect("entry was just pushed")
930    }
931
932    /// Build and append the next [`AuditEntry`] with lineage fields in one atomic step.
933    ///
934    /// Equivalent to [`AuditLog::next_entry`] but attaches agent-topology metadata.
935    /// `seq` and `previous_hash` are derived automatically from the log's current state.
936    ///
937    /// ## Parameters
938    ///
939    /// - `event_type` — category of the governance event.
940    /// - `timestamp_ns` — nanoseconds since Unix epoch (caller-supplied for `no_std` compatibility).
941    /// - `payload` — pre-serialized UTF-8 string (JSON in practice).
942    /// - `lineage` — optional agent-topology fields; `Lineage::default()` produces the same hash
943    ///   as [`AuditLog::next_entry`] with the same base fields.
944    ///
945    /// Returns a reference to the newly appended entry.
946    pub fn next_entry_with_lineage(
947        &mut self,
948        event_type: AuditEventType,
949        timestamp_ns: u64,
950        payload: String,
951        lineage: Lineage,
952    ) -> &AuditEntry {
953        let entry = AuditEntry::new_with_lineage(
954            self.next_seq,
955            timestamp_ns,
956            event_type,
957            self.agent_id,
958            self.session_id,
959            payload,
960            self.last_hash,
961            lineage,
962        );
963        self.push(entry)
964            .expect("next_entry_with_lineage invariant: push cannot fail");
965        self.entries.last().expect("entry was just pushed")
966    }
967
968    /// Re-validate the entire log in O(n), checking both invariants for every entry.
969    ///
970    /// Returns `true` if:
971    /// - Every entry passes [`AuditEntry::verify_integrity`] (SHA-256 matches stored hash).
972    /// - Every entry's `seq` is exactly one greater than the previous entry's `seq`
973    ///   (first entry must have `seq = 0`).
974    /// - Every entry's `previous_hash` matches the preceding entry's `entry_hash`
975    ///   (first entry must have `previous_hash = [0u8; 32]`).
976    ///
977    /// Returns `true` for an empty log (vacuously valid).
978    pub fn verify_chain(&self) -> bool {
979        let mut expected_prev_hash: [u8; 32] = [0u8; 32];
980
981        for (expected_seq, entry) in self.entries.iter().enumerate() {
982            if !entry.verify_integrity() {
983                return false;
984            }
985            if entry.seq() != expected_seq as u64 {
986                return false;
987            }
988            if entry.previous_hash() != &expected_prev_hash {
989                return false;
990            }
991            expected_prev_hash = *entry.entry_hash();
992        }
993        true
994    }
995}
996
997// ---------------------------------------------------------------------------
998// Tests
999// ---------------------------------------------------------------------------
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    // Shared test fixtures
1006    const AGENT_BYTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
1007    const SESSION_BYTES: [u8; 16] = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32];
1008    const GENESIS_HASH: [u8; 32] = [0u8; 32];
1009
1010    fn make_entry(seq: u64) -> AuditEntry {
1011        AuditEntry::new(
1012            seq,
1013            1_714_222_134_000_000_000,
1014            AuditEventType::ToolCallIntercepted,
1015            AgentId::from_bytes(AGENT_BYTES),
1016            SessionId::from_bytes(SESSION_BYTES),
1017            alloc::string::String::from("{\"tool\":\"bash\",\"args\":{\"cmd\":\"ls\"}}"),
1018            GENESIS_HASH,
1019        )
1020    }
1021
1022    // --- AuditEventType ---
1023
1024    #[test]
1025    fn event_type_as_str_all_variants() {
1026        assert_eq!(AuditEventType::ToolCallIntercepted.as_str(), "ToolCallIntercepted");
1027        assert_eq!(AuditEventType::PolicyViolation.as_str(), "PolicyViolation");
1028        assert_eq!(AuditEventType::CredentialLeakBlocked.as_str(), "CredentialLeakBlocked");
1029        assert_eq!(AuditEventType::ApprovalRequested.as_str(), "ApprovalRequested");
1030        assert_eq!(AuditEventType::ApprovalGranted.as_str(), "ApprovalGranted");
1031        assert_eq!(AuditEventType::ApprovalDenied.as_str(), "ApprovalDenied");
1032        assert_eq!(AuditEventType::BudgetLimitApproached.as_str(), "BudgetLimitApproached");
1033        assert_eq!(AuditEventType::BudgetLimitExceeded.as_str(), "BudgetLimitExceeded");
1034        assert_eq!(AuditEventType::ApprovalTimedOut.as_str(), "ApprovalTimedOut");
1035        assert_eq!(AuditEventType::ApprovalRouted.as_str(), "ApprovalRouted");
1036        assert_eq!(AuditEventType::ApprovalEscalated.as_str(), "ApprovalEscalated");
1037        assert_eq!(AuditEventType::ToolDispatched.as_str(), "ToolDispatched");
1038        assert_eq!(AuditEventType::SandboxStarted.as_str(), "SandboxStarted");
1039        assert_eq!(
1040            AuditEventType::SandboxFilesystemBlocked.as_str(),
1041            "SandboxFilesystemBlocked"
1042        );
1043        assert_eq!(AuditEventType::SandboxCpuTimeout.as_str(), "SandboxCpuTimeout");
1044        assert_eq!(AuditEventType::SandboxOomKilled.as_str(), "SandboxOomKilled");
1045        assert_eq!(AuditEventType::SandboxTerminated.as_str(), "SandboxTerminated");
1046    }
1047
1048    #[test]
1049    fn event_type_discriminants_are_0_through_10() {
1050        assert_eq!(AuditEventType::ToolCallIntercepted as u32, 0);
1051        assert_eq!(AuditEventType::PolicyViolation as u32, 1);
1052        assert_eq!(AuditEventType::CredentialLeakBlocked as u32, 2);
1053        assert_eq!(AuditEventType::ApprovalRequested as u32, 3);
1054        assert_eq!(AuditEventType::ApprovalGranted as u32, 4);
1055        assert_eq!(AuditEventType::ApprovalDenied as u32, 5);
1056        assert_eq!(AuditEventType::BudgetLimitApproached as u32, 6);
1057        assert_eq!(AuditEventType::BudgetLimitExceeded as u32, 7);
1058        assert_eq!(AuditEventType::ApprovalTimedOut as u32, 8);
1059        assert_eq!(AuditEventType::ApprovalRouted as u32, 9);
1060        assert_eq!(AuditEventType::ApprovalEscalated as u32, 10);
1061        assert_eq!(AuditEventType::ToolDispatched as u32, 13);
1062        assert_eq!(AuditEventType::SandboxStarted as u32, 16);
1063        assert_eq!(AuditEventType::SandboxFilesystemBlocked as u32, 17);
1064        assert_eq!(AuditEventType::SandboxCpuTimeout as u32, 18);
1065        assert_eq!(AuditEventType::SandboxOomKilled as u32, 19);
1066        assert_eq!(AuditEventType::SandboxTerminated as u32, 20);
1067    }
1068
1069    #[test]
1070    fn event_type_variants_are_all_distinct() {
1071        let variants = [
1072            AuditEventType::ToolCallIntercepted,
1073            AuditEventType::PolicyViolation,
1074            AuditEventType::CredentialLeakBlocked,
1075            AuditEventType::ApprovalRequested,
1076            AuditEventType::ApprovalGranted,
1077            AuditEventType::ApprovalDenied,
1078            AuditEventType::BudgetLimitApproached,
1079            AuditEventType::BudgetLimitExceeded,
1080            AuditEventType::ApprovalTimedOut,
1081            AuditEventType::ApprovalRouted,
1082            AuditEventType::ApprovalEscalated,
1083            AuditEventType::ToolDispatched,
1084            AuditEventType::SandboxStarted,
1085            AuditEventType::SandboxFilesystemBlocked,
1086            AuditEventType::SandboxCpuTimeout,
1087            AuditEventType::SandboxOomKilled,
1088            AuditEventType::SandboxTerminated,
1089        ];
1090        for i in 0..variants.len() {
1091            for j in (i + 1)..variants.len() {
1092                assert_ne!(variants[i], variants[j]);
1093            }
1094        }
1095    }
1096
1097    // --- AuditEntry::new() and getters ---
1098
1099    #[test]
1100    fn new_produces_nonzero_entry_hash() {
1101        let entry = make_entry(0);
1102        assert_ne!(entry.entry_hash(), &[0u8; 32]);
1103    }
1104
1105    #[test]
1106    fn getters_return_correct_values() {
1107        let payload = alloc::string::String::from("{\"k\":\"v\"}");
1108        let entry = AuditEntry::new(
1109            42,
1110            999_000_000,
1111            AuditEventType::PolicyViolation,
1112            AgentId::from_bytes(AGENT_BYTES),
1113            SessionId::from_bytes(SESSION_BYTES),
1114            payload.clone(),
1115            GENESIS_HASH,
1116        );
1117        assert_eq!(entry.seq(), 42);
1118        assert_eq!(entry.timestamp_ns(), 999_000_000);
1119        assert_eq!(entry.event_type(), AuditEventType::PolicyViolation);
1120        assert_eq!(entry.agent_id(), AgentId::from_bytes(AGENT_BYTES));
1121        assert_eq!(entry.session_id(), SessionId::from_bytes(SESSION_BYTES));
1122        assert_eq!(entry.payload(), "{\"k\":\"v\"}");
1123        assert_eq!(entry.previous_hash(), &GENESIS_HASH);
1124    }
1125
1126    #[test]
1127    fn genesis_entry_uses_zero_previous_hash() {
1128        let entry = make_entry(0);
1129        assert_eq!(entry.previous_hash(), &[0u8; 32]);
1130    }
1131
1132    // --- verify_integrity() ---
1133
1134    #[test]
1135    fn verify_integrity_true_for_untampered_entry() {
1136        assert!(make_entry(0).verify_integrity());
1137    }
1138
1139    #[test]
1140    fn verify_integrity_false_after_seq_tamper() {
1141        let mut entry = make_entry(0);
1142        // SAFETY: deliberate tampering to test integrity detection.
1143        unsafe {
1144            let ptr = &mut entry.seq as *mut u64;
1145            *ptr = 999;
1146        }
1147        assert!(!entry.verify_integrity());
1148    }
1149
1150    #[test]
1151    fn verify_integrity_false_after_payload_tamper() {
1152        let mut entry = make_entry(0);
1153        // SAFETY: deliberate tampering to test integrity detection.
1154        unsafe {
1155            let ptr = entry.payload.as_mut_vec();
1156            if let Some(b) = ptr.first_mut() {
1157                *b = b'X';
1158            }
1159        }
1160        assert!(!entry.verify_integrity());
1161    }
1162
1163    #[test]
1164    fn verify_integrity_false_after_event_type_tamper() {
1165        let mut entry = make_entry(0);
1166        // SAFETY: deliberate tampering to test integrity detection.
1167        unsafe {
1168            let ptr = &mut entry.event_type as *mut AuditEventType;
1169            *ptr = AuditEventType::BudgetLimitExceeded;
1170        }
1171        assert!(!entry.verify_integrity());
1172    }
1173
1174    #[test]
1175    fn verify_integrity_false_after_previous_hash_tamper() {
1176        let mut entry = make_entry(0);
1177        // SAFETY: deliberate tampering to test integrity detection.
1178        unsafe {
1179            let ptr = &mut entry.previous_hash as *mut [u8; 32];
1180            (*ptr)[0] = 0xFF;
1181        }
1182        assert!(!entry.verify_integrity());
1183    }
1184
1185    // --- Hash chain linkage ---
1186
1187    #[test]
1188    fn chained_entries_have_distinct_hashes() {
1189        let first = make_entry(0);
1190        let second = AuditEntry::new(
1191            1,
1192            1_714_222_134_000_000_001,
1193            AuditEventType::PolicyViolation,
1194            AgentId::from_bytes(AGENT_BYTES),
1195            SessionId::from_bytes(SESSION_BYTES),
1196            alloc::string::String::from("{\"rule\":\"deny\"}"),
1197            *first.entry_hash(),
1198        );
1199        assert_ne!(first.entry_hash(), second.entry_hash());
1200        assert_eq!(second.previous_hash(), first.entry_hash());
1201        assert!(second.verify_integrity());
1202    }
1203
1204    #[test]
1205    fn different_seq_produces_different_hash() {
1206        let a = make_entry(0);
1207        let b = make_entry(1);
1208        assert_ne!(a.entry_hash(), b.entry_hash());
1209    }
1210
1211    #[test]
1212    fn different_previous_hash_produces_different_entry_hash() {
1213        let prev_a = [0u8; 32];
1214        let mut prev_b = [0u8; 32];
1215        prev_b[0] = 1;
1216
1217        let a = AuditEntry::new(
1218            0,
1219            0,
1220            AuditEventType::ToolCallIntercepted,
1221            AgentId::from_bytes(AGENT_BYTES),
1222            SessionId::from_bytes(SESSION_BYTES),
1223            alloc::string::String::from("{}"),
1224            prev_a,
1225        );
1226        let b = AuditEntry::new(
1227            0,
1228            0,
1229            AuditEventType::ToolCallIntercepted,
1230            AgentId::from_bytes(AGENT_BYTES),
1231            SessionId::from_bytes(SESSION_BYTES),
1232            alloc::string::String::from("{}"),
1233            prev_b,
1234        );
1235        assert_ne!(a.entry_hash(), b.entry_hash());
1236    }
1237
1238    // --- Display ---
1239
1240    #[test]
1241    fn display_contains_seq_ts_and_event_name() {
1242        let entry = make_entry(7);
1243        let s = alloc::format!("{}", entry);
1244        assert!(s.starts_with('['));
1245        assert!(s.ends_with(']'));
1246        assert!(s.contains("seq=7"));
1247        assert!(s.contains("ts=1714222134000000000"));
1248        assert!(s.contains("event=ToolCallIntercepted"));
1249    }
1250
1251    #[test]
1252    fn display_contains_agent_and_session_hex() {
1253        let entry = make_entry(0);
1254        let s = alloc::format!("{}", entry);
1255        // AGENT_BYTES starts with 01 02 03 04
1256        assert!(s.contains("agent=01020304"));
1257        // SESSION_BYTES starts with 11 12 13 14
1258        assert!(s.contains("session=11121314"));
1259    }
1260
1261    #[test]
1262    fn display_does_not_contain_payload() {
1263        let entry = make_entry(0);
1264        let s = alloc::format!("{}", entry);
1265        assert!(!s.contains("bash"));
1266    }
1267
1268    #[test]
1269    fn display_round_trips_sandbox_event_names() {
1270        // For each Sandbox* lifecycle variant introduced under AAASM-1965,
1271        // assert that AuditEntry's Display surfaces the variant's `as_str()`
1272        // label verbatim. Auditors grep the JSONL log by these tokens.
1273        let sandbox_events = [
1274            (AuditEventType::SandboxStarted, "event=SandboxStarted]"),
1275            (
1276                AuditEventType::SandboxFilesystemBlocked,
1277                "event=SandboxFilesystemBlocked]",
1278            ),
1279            (AuditEventType::SandboxCpuTimeout, "event=SandboxCpuTimeout]"),
1280            (AuditEventType::SandboxOomKilled, "event=SandboxOomKilled]"),
1281            (AuditEventType::SandboxTerminated, "event=SandboxTerminated]"),
1282        ];
1283        for (event_type, expected_tail) in sandbox_events {
1284            let entry = AuditEntry::new(
1285                0,
1286                1_714_222_134_000_000_000,
1287                event_type,
1288                AgentId::from_bytes(AGENT_BYTES),
1289                SessionId::from_bytes(SESSION_BYTES),
1290                alloc::string::String::from("{}"),
1291                GENESIS_HASH,
1292            );
1293            let rendered = alloc::format!("{}", entry);
1294            assert!(
1295                rendered.ends_with(expected_tail),
1296                "Display for {:?} should end with `{}` but was `{}`",
1297                event_type,
1298                expected_tail,
1299                rendered,
1300            );
1301        }
1302    }
1303
1304    // --- AuditLog helpers ---
1305
1306    fn make_log() -> AuditLog {
1307        AuditLog::new(AgentId::from_bytes(AGENT_BYTES), SessionId::from_bytes(SESSION_BYTES))
1308    }
1309
1310    fn make_valid_entry(seq: u64, previous_hash: [u8; 32]) -> AuditEntry {
1311        AuditEntry::new(
1312            seq,
1313            1_000_000_000,
1314            AuditEventType::ToolCallIntercepted,
1315            AgentId::from_bytes(AGENT_BYTES),
1316            SessionId::from_bytes(SESSION_BYTES),
1317            alloc::string::String::from("{}"),
1318            previous_hash,
1319        )
1320    }
1321
1322    // --- AuditLog::push() ---
1323
1324    #[test]
1325    fn push_genesis_entry_succeeds() {
1326        let mut log = make_log();
1327        let entry = make_valid_entry(0, GENESIS_HASH);
1328        assert!(log.push(entry).is_ok());
1329        assert_eq!(log.len(), 1);
1330    }
1331
1332    #[test]
1333    fn push_rejects_seq_gap_skipping_forward() {
1334        let mut log = make_log();
1335        let entry = make_valid_entry(2, GENESIS_HASH); // expected seq=0
1336        let err = log.push(entry).unwrap_err();
1337        assert_eq!(err, AuditLogError::SequenceGap { expected: 0, got: 2 });
1338        assert!(log.is_empty(), "log must be unmodified on error");
1339    }
1340
1341    #[test]
1342    fn push_rejects_seq_going_backward() {
1343        let mut log = make_log();
1344        let e0 = make_valid_entry(0, GENESIS_HASH);
1345        let hash0 = *e0.entry_hash();
1346        log.push(e0).unwrap();
1347
1348        let e_back = make_valid_entry(0, hash0); // duplicate seq=0
1349        let err = log.push(e_back).unwrap_err();
1350        assert_eq!(err, AuditLogError::SequenceGap { expected: 1, got: 0 });
1351        assert_eq!(log.len(), 1, "log must be unmodified on error");
1352    }
1353
1354    #[test]
1355    fn push_rejects_broken_hash_chain() {
1356        let mut log = make_log();
1357        let e0 = make_valid_entry(0, GENESIS_HASH);
1358        log.push(e0).unwrap();
1359
1360        let wrong_prev = [0xAB; 32]; // not equal to e0.entry_hash()
1361        let e1 = make_valid_entry(1, wrong_prev);
1362        let err = log.push(e1).unwrap_err();
1363        assert_eq!(err, AuditLogError::HashChainBroken { at_seq: 1 });
1364        assert_eq!(log.len(), 1, "log must be unmodified on error");
1365    }
1366
1367    #[test]
1368    fn push_two_valid_entries_succeeds() {
1369        let mut log = make_log();
1370        let e0 = make_valid_entry(0, GENESIS_HASH);
1371        let hash0 = *e0.entry_hash();
1372        log.push(e0).unwrap();
1373
1374        let e1 = make_valid_entry(1, hash0);
1375        log.push(e1).unwrap();
1376
1377        assert_eq!(log.len(), 2);
1378        assert_eq!(log.entries()[0].seq(), 0);
1379        assert_eq!(log.entries()[1].seq(), 1);
1380    }
1381
1382    #[test]
1383    fn audit_log_error_display_sequence_gap() {
1384        let err = AuditLogError::SequenceGap { expected: 3, got: 7 };
1385        let s = alloc::format!("{}", err);
1386        assert!(s.contains("expected seq=3"));
1387        assert!(s.contains("got seq=7"));
1388    }
1389
1390    #[test]
1391    fn audit_log_error_display_hash_chain_broken() {
1392        let err = AuditLogError::HashChainBroken { at_seq: 5 };
1393        let s = alloc::format!("{}", err);
1394        assert!(s.contains("at_seq=5") || s.contains("at seq=5"));
1395    }
1396
1397    // --- AuditLog::next_entry() ---
1398
1399    #[test]
1400    fn next_entry_genesis_has_seq_zero_and_zero_prev_hash() {
1401        let mut log = make_log();
1402        let e = log.next_entry(
1403            AuditEventType::ToolCallIntercepted,
1404            1_000,
1405            alloc::string::String::from("{}"),
1406        );
1407        assert_eq!(e.seq(), 0);
1408        assert_eq!(e.previous_hash(), &GENESIS_HASH);
1409        assert!(e.verify_integrity());
1410    }
1411
1412    #[test]
1413    fn next_entry_auto_increments_seq() {
1414        let mut log = make_log();
1415        log.next_entry(
1416            AuditEventType::ToolCallIntercepted,
1417            1_000,
1418            alloc::string::String::from("{}"),
1419        );
1420        log.next_entry(
1421            AuditEventType::PolicyViolation,
1422            2_000,
1423            alloc::string::String::from("{}"),
1424        );
1425        log.next_entry(
1426            AuditEventType::ApprovalGranted,
1427            3_000,
1428            alloc::string::String::from("{}"),
1429        );
1430
1431        assert_eq!(log.len(), 3);
1432        assert_eq!(log.entries()[0].seq(), 0);
1433        assert_eq!(log.entries()[1].seq(), 1);
1434        assert_eq!(log.entries()[2].seq(), 2);
1435    }
1436
1437    #[test]
1438    fn next_entry_links_previous_hash_correctly() {
1439        let mut log = make_log();
1440        log.next_entry(
1441            AuditEventType::ToolCallIntercepted,
1442            1_000,
1443            alloc::string::String::from("{}"),
1444        );
1445        log.next_entry(
1446            AuditEventType::PolicyViolation,
1447            2_000,
1448            alloc::string::String::from("{}"),
1449        );
1450
1451        let e0_hash = *log.entries()[0].entry_hash();
1452        assert_eq!(log.entries()[1].previous_hash(), &e0_hash);
1453    }
1454
1455    #[test]
1456    fn next_entry_mixed_with_push_works_correctly() {
1457        let mut log = make_log();
1458        // First entry via next_entry
1459        log.next_entry(
1460            AuditEventType::ToolCallIntercepted,
1461            1_000,
1462            alloc::string::String::from("{}"),
1463        );
1464        let hash0 = *log.entries()[0].entry_hash();
1465
1466        // Second entry via manual push with correct seq and previous_hash
1467        let e1 = make_valid_entry(1, hash0);
1468        log.push(e1).unwrap();
1469
1470        // Third entry via next_entry — should pick up seq=2 and hash1
1471        log.next_entry(
1472            AuditEventType::ApprovalGranted,
1473            3_000,
1474            alloc::string::String::from("{}"),
1475        );
1476
1477        assert_eq!(log.len(), 3);
1478        assert_eq!(log.entries()[2].seq(), 2);
1479        assert_eq!(log.entries()[2].previous_hash(), log.entries()[1].entry_hash());
1480    }
1481
1482    #[test]
1483    fn next_entry_all_entries_pass_verify_integrity() {
1484        let mut log = make_log();
1485        for i in 0..5 {
1486            log.next_entry(
1487                AuditEventType::ToolCallIntercepted,
1488                i * 1_000,
1489                alloc::string::String::from("{}"),
1490            );
1491        }
1492        for entry in log.entries() {
1493            assert!(entry.verify_integrity());
1494        }
1495    }
1496
1497    // --- AuditLog::verify_chain() ---
1498
1499    #[test]
1500    fn verify_chain_empty_log_returns_true() {
1501        assert!(make_log().verify_chain());
1502    }
1503
1504    #[test]
1505    fn verify_chain_valid_log_returns_true() {
1506        let mut log = make_log();
1507        for i in 0..4 {
1508            log.next_entry(
1509                AuditEventType::ToolCallIntercepted,
1510                i * 1_000,
1511                alloc::string::String::from("{}"),
1512            );
1513        }
1514        assert!(log.verify_chain());
1515    }
1516
1517    #[test]
1518    fn verify_chain_false_after_unsafe_seq_tamper() {
1519        let mut log = make_log();
1520        log.next_entry(
1521            AuditEventType::ToolCallIntercepted,
1522            1_000,
1523            alloc::string::String::from("{}"),
1524        );
1525        log.next_entry(
1526            AuditEventType::PolicyViolation,
1527            2_000,
1528            alloc::string::String::from("{}"),
1529        );
1530
1531        // Tamper the seq of the first entry.
1532        // SAFETY: deliberate tampering to test verify_chain detection.
1533        unsafe {
1534            let entry = &mut *(log.entries.as_mut_ptr());
1535            let ptr = &mut entry.seq as *mut u64;
1536            *ptr = 99;
1537        }
1538        assert!(!log.verify_chain());
1539    }
1540
1541    #[test]
1542    fn verify_chain_false_after_unsafe_payload_tamper() {
1543        let mut log = make_log();
1544        log.next_entry(
1545            AuditEventType::ToolCallIntercepted,
1546            1_000,
1547            alloc::string::String::from("{}"),
1548        );
1549        log.next_entry(
1550            AuditEventType::PolicyViolation,
1551            2_000,
1552            alloc::string::String::from("{}"),
1553        );
1554
1555        // Tamper the payload of the second entry — breaks its verify_integrity().
1556        // SAFETY: deliberate tampering to test verify_chain detection.
1557        unsafe {
1558            let entry = &mut *(log.entries.as_mut_ptr().add(1));
1559            if let Some(b) = entry.payload.as_mut_vec().first_mut() {
1560                *b = b'X';
1561            }
1562        }
1563        assert!(!log.verify_chain());
1564    }
1565
1566    #[test]
1567    fn verify_chain_false_after_unsafe_previous_hash_tamper() {
1568        let mut log = make_log();
1569        log.next_entry(
1570            AuditEventType::ToolCallIntercepted,
1571            1_000,
1572            alloc::string::String::from("{}"),
1573        );
1574        log.next_entry(
1575            AuditEventType::PolicyViolation,
1576            2_000,
1577            alloc::string::String::from("{}"),
1578        );
1579
1580        // Tamper previous_hash of the second entry — breaks chain linkage check.
1581        // SAFETY: deliberate tampering to test verify_chain detection.
1582        unsafe {
1583            let entry = &mut *(log.entries.as_mut_ptr().add(1));
1584            let ptr = &mut entry.previous_hash as *mut [u8; 32];
1585            (*ptr)[0] = 0xFF;
1586        }
1587        assert!(!log.verify_chain());
1588    }
1589
1590    // --- audit_entry_for_tool_dispatch (AAASM-1920 Secret Injection) ---
1591
1592    #[test]
1593    fn tool_dispatch_helper_emits_placeholder_form_payload() {
1594        // Synthetic — fabricated for this test only.
1595        let real_secret = "real-secret-abc-DEADBEEF-0001";
1596        let placeholder_args = serde_json::json!({
1597            "connection_string": "${DB_PASSWORD}"
1598        });
1599
1600        let entry = audit_entry_for_tool_dispatch(
1601            42,
1602            1_714_222_134_000_000_000,
1603            AgentId::from_bytes(AGENT_BYTES),
1604            SessionId::from_bytes(SESSION_BYTES),
1605            &placeholder_args,
1606            GENESIS_HASH,
1607        );
1608
1609        assert_eq!(entry.event_type(), AuditEventType::ToolDispatched);
1610        // Payload carries the placeholder-form, not the resolved value.
1611        assert!(entry.payload().contains("${DB_PASSWORD}"));
1612        assert!(
1613            !entry.payload().contains(real_secret),
1614            "audit payload MUST NOT contain the resolved credential — placeholder-form contract"
1615        );
1616    }
1617}
1618
1619#[cfg(all(test, feature = "alloc", feature = "serde"))]
1620mod lineage_tests {
1621    use super::*;
1622
1623    const AGENT: AgentId = AgentId::from_bytes([1u8; 16]);
1624    const SESSION: SessionId = SessionId::from_bytes([2u8; 16]);
1625    const ROOT: AgentId = AgentId::from_bytes([7u8; 16]);
1626    const PARENT: AgentId = AgentId::from_bytes([9u8; 16]);
1627
1628    fn base_entry() -> AuditEntry {
1629        AuditEntry::new(
1630            0,
1631            1_700_000_000_000_000_000,
1632            AuditEventType::ToolCallIntercepted,
1633            AGENT,
1634            SESSION,
1635            r#"{"tool":"bash"}"#.into(),
1636            [0u8; 32],
1637        )
1638    }
1639
1640    #[test]
1641    fn lineage_default_is_all_none() {
1642        let l = Lineage::default();
1643        assert!(l.root_agent_id.is_none());
1644        assert!(l.parent_agent_id.is_none());
1645        assert!(l.team_id.is_none());
1646        assert!(l.delegation_reason.is_none());
1647        assert!(l.spawned_by_tool.is_none());
1648        assert!(l.depth.is_none());
1649    }
1650
1651    #[test]
1652    fn new_with_empty_lineage_produces_same_hash_as_new() {
1653        let legacy = base_entry();
1654        let with_lineage = AuditEntry::new_with_lineage(
1655            0,
1656            1_700_000_000_000_000_000,
1657            AuditEventType::ToolCallIntercepted,
1658            AGENT,
1659            SESSION,
1660            r#"{"tool":"bash"}"#.into(),
1661            [0u8; 32],
1662            Lineage::default(),
1663        );
1664        assert_eq!(
1665            legacy.entry_hash(),
1666            with_lineage.entry_hash(),
1667            "Lineage::default() must not change the hash"
1668        );
1669    }
1670
1671    #[test]
1672    fn new_with_lineage_getters_return_correct_values() {
1673        let lineage = Lineage {
1674            root_agent_id: Some(ROOT),
1675            parent_agent_id: Some(PARENT),
1676            team_id: Some("team-alpha".into()),
1677            org_id: None,
1678            delegation_reason: Some("summarise".into()),
1679            spawned_by_tool: Some("langgraph".into()),
1680            depth: Some(2),
1681        };
1682        let entry = AuditEntry::new_with_lineage(
1683            0,
1684            1_000,
1685            AuditEventType::PolicyViolation,
1686            AGENT,
1687            SESSION,
1688            "{}".into(),
1689            [0u8; 32],
1690            lineage,
1691        );
1692        assert_eq!(entry.root_agent_id(), Some(ROOT));
1693        assert_eq!(entry.parent_agent_id(), Some(PARENT));
1694        assert_eq!(entry.team_id(), Some("team-alpha"));
1695        assert_eq!(entry.delegation_reason(), Some("summarise"));
1696        assert_eq!(entry.spawned_by_tool(), Some("langgraph"));
1697        assert_eq!(entry.depth(), Some(2));
1698    }
1699
1700    #[test]
1701    fn verify_integrity_true_with_lineage() {
1702        let lineage = Lineage {
1703            root_agent_id: Some(ROOT),
1704            team_id: Some("ops".into()),
1705            depth: Some(1),
1706            ..Lineage::default()
1707        };
1708        let entry = AuditEntry::new_with_lineage(
1709            0,
1710            1_000,
1711            AuditEventType::ToolCallIntercepted,
1712            AGENT,
1713            SESSION,
1714            "{}".into(),
1715            [0u8; 32],
1716            lineage,
1717        );
1718        assert!(entry.verify_integrity());
1719    }
1720
1721    #[test]
1722    fn lineage_fields_change_hash() {
1723        let no_lineage = base_entry();
1724        let lineage = Lineage {
1725            depth: Some(1),
1726            ..Lineage::default()
1727        };
1728        let with_depth = AuditEntry::new_with_lineage(
1729            0,
1730            1_700_000_000_000_000_000,
1731            AuditEventType::ToolCallIntercepted,
1732            AGENT,
1733            SESSION,
1734            r#"{"tool":"bash"}"#.into(),
1735            [0u8; 32],
1736            lineage,
1737        );
1738        assert_ne!(
1739            no_lineage.entry_hash(),
1740            with_depth.entry_hash(),
1741            "A present lineage field must change the hash"
1742        );
1743    }
1744
1745    #[test]
1746    fn serde_round_trip_with_lineage() {
1747        let lineage = Lineage {
1748            root_agent_id: Some(ROOT),
1749            parent_agent_id: Some(PARENT),
1750            team_id: Some("t1".into()),
1751            org_id: Some("o1".into()),
1752            delegation_reason: Some("r".into()),
1753            spawned_by_tool: Some("s".into()),
1754            depth: Some(3),
1755        };
1756        let entry = AuditEntry::new_with_lineage(
1757            0,
1758            1_000,
1759            AuditEventType::ToolCallIntercepted,
1760            AGENT,
1761            SESSION,
1762            "{}".into(),
1763            [0u8; 32],
1764            lineage,
1765        );
1766        let json = serde_json::to_string(&entry).unwrap();
1767        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1768        assert_eq!(entry.entry_hash(), restored.entry_hash());
1769        assert_eq!(restored.root_agent_id(), Some(ROOT));
1770        assert_eq!(restored.depth(), Some(3));
1771    }
1772
1773    #[test]
1774    fn legacy_jsonl_without_lineage_fields_deserialises_and_verifies() {
1775        let pre_change_entry = AuditEntry::new(
1776            0,
1777            1_700_000_000_000_000_000,
1778            AuditEventType::ToolCallIntercepted,
1779            AGENT,
1780            SESSION,
1781            r#"{"tool":"bash"}"#.into(),
1782            [0u8; 32],
1783        );
1784        let json = serde_json::to_string(&pre_change_entry).unwrap();
1785        assert!(!json.contains("root_agent_id"), "None fields must not appear in JSON");
1786        let restored: AuditEntry = serde_json::from_str(&json).unwrap();
1787        assert!(restored.root_agent_id().is_none());
1788        assert!(
1789            restored.verify_integrity(),
1790            "Legacy entries must still verify after adding lineage fields"
1791        );
1792    }
1793
1794    #[test]
1795    fn next_entry_with_lineage_links_chain() {
1796        let mut log = AuditLog::new(AGENT, SESSION);
1797        let lineage = Lineage {
1798            depth: Some(1),
1799            team_id: Some("t".into()),
1800            ..Lineage::default()
1801        };
1802        log.next_entry_with_lineage(AuditEventType::ToolCallIntercepted, 1_000, "{}".into(), lineage.clone());
1803        log.next_entry_with_lineage(AuditEventType::PolicyViolation, 2_000, "{}".into(), lineage);
1804        assert!(log.verify_chain());
1805        assert_eq!(log.len(), 2);
1806    }
1807}
1808
1809#[cfg(all(test, feature = "std", feature = "serde"))]
1810mod redaction_tests {
1811    use super::*;
1812    use crate::scanner::CredentialScanner;
1813
1814    const AGENT: AgentId = AgentId::from_bytes([3u8; 16]);
1815    const SESSION: SessionId = SessionId::from_bytes([4u8; 16]);
1816
1817    /// Synthetic AWS access key from AWS public documentation. Not a real credential.
1818    const FAKE_AWS_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE";
1819
1820    fn build_redaction_for_fake_secret() -> Redaction {
1821        let scanner = CredentialScanner::new();
1822        let scan = scanner.scan(FAKE_AWS_ACCESS_KEY);
1823        assert!(
1824            !scan.findings.is_empty(),
1825            "scanner must detect the synthetic AWS access key — fixture invariant",
1826        );
1827        let redacted = scan.redact(FAKE_AWS_ACCESS_KEY);
1828        Redaction {
1829            credential_findings: scan.findings,
1830            redacted_payload: Some(redacted),
1831        }
1832    }
1833
1834    #[test]
1835    fn audit_entry_with_redaction_never_serializes_the_raw_secret() {
1836        let redaction = build_redaction_for_fake_secret();
1837        // Decision metadata only — no raw secret bytes in the audit payload itself.
1838        let payload = String::from(r#"{"action_type":"tool_call","decision":"redact"}"#);
1839        let entry = AuditEntry::new_with_lineage_and_redaction(
1840            0,
1841            1_700_000_000_000_000_000,
1842            AuditEventType::CredentialLeakBlocked,
1843            AGENT,
1844            SESSION,
1845            payload,
1846            [0u8; 32],
1847            Lineage::default(),
1848            redaction,
1849        );
1850
1851        let serialized = serde_json::to_string(&entry).expect("AuditEntry must serialize");
1852
1853        // Primary security invariant: the raw secret bytes never appear in the
1854        // serialized AuditEntry — neither in `payload`, nor in `credential_findings`,
1855        // nor in `redacted_payload`.
1856        assert!(
1857            !serialized.contains(FAKE_AWS_ACCESS_KEY),
1858            "SECURITY INVARIANT VIOLATED: raw secret appears in serialized AuditEntry: {serialized}",
1859        );
1860
1861        // Secondary sanity check: the redaction label IS present, proving findings were attached.
1862        assert!(
1863            serialized.contains("[REDACTED:AwsAccessKey]"),
1864            "serialized AuditEntry must carry the [REDACTED:AwsAccessKey] label, got: {serialized}",
1865        );
1866
1867        // Tamper-evidence holds: the entry validates against its recorded hash.
1868        assert!(
1869            entry.verify_integrity(),
1870            "verify_integrity must pass on a freshly constructed redacted entry",
1871        );
1872    }
1873
1874    #[test]
1875    fn redaction_default_preserves_legacy_hash() {
1876        // new() and new_with_lineage_and_redaction(_, _, ..., Redaction::default())
1877        // must produce identical entry_hash for the same base fields. This guarantees
1878        // the existing audit chain on disk continues to verify after this PR lands.
1879        let payload = String::from(r#"{"tool":"bash"}"#);
1880        let legacy = AuditEntry::new(
1881            0,
1882            1_700_000_000_000_000_000,
1883            AuditEventType::ToolCallIntercepted,
1884            AGENT,
1885            SESSION,
1886            payload.clone(),
1887            [0u8; 32],
1888        );
1889        let with_default_redaction = AuditEntry::new_with_lineage_and_redaction(
1890            0,
1891            1_700_000_000_000_000_000,
1892            AuditEventType::ToolCallIntercepted,
1893            AGENT,
1894            SESSION,
1895            payload,
1896            [0u8; 32],
1897            Lineage::default(),
1898            Redaction::default(),
1899        );
1900        assert_eq!(
1901            legacy.entry_hash(),
1902            with_default_redaction.entry_hash(),
1903            "Redaction::default() must contribute 0 bytes to the hash so legacy chains keep verifying",
1904        );
1905    }
1906}