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