Skip to main content

ai_memory/
audit.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Enterprise audit trail (PR-5 of issue #487).
5//!
6//! Every memory-mutation call site in the binary — HTTP handlers, MCP
7//! tool dispatch, CLI write commands, and `ai-memory boot` — emits an
8//! [`AuditEvent`] to a hash-chained, append-only JSON log when the
9//! audit subsystem is enabled. The schema is **stable, versioned, and
10//! framework-agnostic** (NOT bound to OCSF or CEF — see
11//! `docs/security/audit-schema.md`). SIEMs ingest the lines as-is.
12//!
13//! # Design properties
14//!
15//! 1. **Default-OFF** for privacy. Operators opt in via
16//!    `[audit] enabled = true` in `config.toml` (or
17//!    `AI_MEMORY_AUDIT_PATH=...` for one-off runs).
18//! 2. **Hash-chained, tamper-evident.** Each line carries a `prev_hash`
19//!    that matches the prior line's `self_hash`. `ai-memory audit
20//!    verify` recomputes the chain and exits non-zero on mismatch.
21//! 3. **Append-only OS hint.** Best-effort `chflags(2)` (BSD/macOS) or
22//!    `FS_IOC_SETFLAGS` ioctl (Linux). Documented as defense in depth;
23//!    the chain is the load-bearing tamper-evidence.
24//! 4. **Privacy by default.** Audit captures `(memory_id, namespace,
25//!    title, action, outcome, actor)`. Memory **content is never
26//!    emitted** — `redact_content = true` is the only supported mode in
27//!    the v1 schema; the field is reserved in [`AuditTarget`] for
28//!    future compliance contexts that mandate content capture.
29//! 5. **Per-process monotonic sequence**, independent of the chain.
30//!    Lets a SIEM detect dropped lines even before the chain check.
31//! 6. **No backpressure on the caller.** Emission is synchronous (one
32//!    write per line so the chain is consistent across processes
33//!    concurrently appending — the file is opened with `O_APPEND`),
34//!    but failures inside emit are swallowed and logged via `tracing`.
35//!    A broken audit pipeline never blocks a memory operation.
36
37use std::fs::{File, OpenOptions};
38use std::io::{BufRead, BufReader, Read, Write};
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Mutex, RwLock};
42
43use anyhow::{Context, Result, anyhow};
44use chrono::Utc;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48/// Stable schema version stamped on every emitted line. Bump only when
49/// a field's semantics change in a way SIEM parsers care about
50/// (renaming, removing, or repurposing). Adding optional fields does
51/// NOT bump the version. See `docs/security/audit-schema.md` §Version
52/// policy for the full contract.
53pub const SCHEMA_VERSION: u32 = 1;
54
55/// Sentinel `prev_hash` for the first line in a fresh chain. Hex-encoded
56/// 32-byte zero buffer — picked so a chain head is unambiguous on
57/// inspection.
58pub const CHAIN_HEAD_PREV_HASH: &str =
59    "0000000000000000000000000000000000000000000000000000000000000000";
60
61/// One audit event. The serialized form is one JSON object per line
62/// (NDJSON). Field order is stable for chain reproducibility.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct AuditEvent {
65    /// Schema version — see [`SCHEMA_VERSION`].
66    pub schema_version: u32,
67    /// RFC3339 UTC timestamp when the event was emitted.
68    pub timestamp: String,
69    /// Per-process monotonic counter starting at 1 on init.
70    pub sequence: u64,
71    pub actor: AuditActor,
72    pub action: AuditAction,
73    pub target: AuditTarget,
74    pub outcome: AuditOutcome,
75    /// Authentication context. `None` for stdio MCP / CLI invocations
76    /// where there is no transport-level auth.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub auth: Option<AuditAuth>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub session_id: Option<String>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub request_id: Option<String>,
83    /// Populated only when `outcome = Error`. Capped at 256 chars to
84    /// prevent error-message based content leaks.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub error: Option<String>,
87    /// Hex-encoded sha256 of the immediately prior line's `self_hash`,
88    /// or [`CHAIN_HEAD_PREV_HASH`] for the first line of a fresh chain.
89    pub prev_hash: String,
90    /// Hex-encoded sha256 of every preceding field in serialization order.
91    pub self_hash: String,
92}
93
94/// Who performed the action.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct AuditActor {
97    /// Resolved NHI agent_id (`ai:<client>@<host>:pid-<n>`,
98    /// `host:<host>:pid-<n>-<uuid>`, etc.). Always present.
99    pub agent_id: String,
100    /// Visibility scope: `private | team | unit | org | collective`.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub scope: Option<String>,
103    /// How `agent_id` was synthesized — surfaces NHI provenance to the
104    /// SIEM. One of: `explicit | env | mcp_client_info | host_fallback
105    /// | anonymous_fallback | http_header | http_body | per_request`.
106    pub synthesis_source: String,
107}
108
109/// Canonical action vocabulary. Adding a variant is a non-breaking
110/// schema change; renaming or removing one IS breaking.
111#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "snake_case")]
113pub enum AuditAction {
114    Recall,
115    Store,
116    Update,
117    Delete,
118    Link,
119    Promote,
120    Forget,
121    Consolidate,
122    Export,
123    Import,
124    Approve,
125    Reject,
126    SessionBoot,
127}
128
129impl AuditAction {
130    /// Wire-format string for log-grep convenience.
131    #[must_use]
132    pub fn as_str(&self) -> &'static str {
133        match self {
134            Self::Recall => "recall",
135            Self::Store => "store",
136            Self::Update => "update",
137            Self::Delete => "delete",
138            Self::Link => "link",
139            Self::Promote => "promote",
140            Self::Forget => "forget",
141            Self::Consolidate => "consolidate",
142            Self::Export => "export",
143            Self::Import => "import",
144            Self::Approve => "approve",
145            Self::Reject => "reject",
146            Self::SessionBoot => "session_boot",
147        }
148    }
149}
150
151/// What was acted upon.
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct AuditTarget {
154    /// Memory id, or `"*"` for a list/sweep operation that touches
155    /// many rows (forget, export, consolidate-many, etc.).
156    pub memory_id: String,
157    /// Memory namespace at the time of the action.
158    pub namespace: String,
159    /// Memory title at the time of the action. Capped at 200 chars and
160    /// stripped of newlines to prevent log-injection. Title is **not**
161    /// content; titles are advisory labels by design (`memory.content`
162    /// is the secret payload and is **never** emitted).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub title: Option<String>,
165    /// Memory tier (`short | mid | long`) at action time.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub tier: Option<String>,
168    /// Memory `metadata.scope` at action time.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub scope: Option<String>,
171}
172
173/// Outcome of the action.
174#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum AuditOutcome {
177    Allow,
178    Deny,
179    Error,
180    Pending,
181}
182
183/// Authentication context for HTTP-originated events. Stdio (CLI / MCP)
184/// invocations omit this block entirely.
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct AuditAuth {
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub source_ip: Option<String>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub mtls_fp: Option<String>,
191    /// **Hash** of the API key id, never the raw key. Hex-encoded
192    /// sha256 truncated to 16 bytes.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub api_key_id_hash: Option<String>,
195}
196
197// ---------------------------------------------------------------------------
198// Sink — process-wide singleton holding the file handle + chain head.
199// ---------------------------------------------------------------------------
200
201/// Process-wide audit sink. `None` when audit is disabled. Wrapped in
202/// `RwLock` (rather than `OnceLock`) so tests can swap in an
203/// in-memory sink between cases without leaking state across runs.
204static SINK: RwLock<Option<std::sync::Arc<AuditSink>>> = RwLock::new(None);
205/// Per-process monotonic sequence counter. Starts at 1 on first emit.
206static SEQUENCE: AtomicU64 = AtomicU64::new(0);
207
208/// Initialised audit sink — writer handle protected by a mutex so the
209/// chain head update + write are atomic across emission threads. The
210/// writer is `dyn Write + Send` so tests can substitute an in-memory
211/// `Vec<u8>` for the production `File`.
212pub(crate) struct AuditSink {
213    inner: Mutex<SinkInner>,
214    #[allow(dead_code)]
215    redact_content: bool,
216}
217
218struct SinkInner {
219    writer: Box<dyn Write + Send>,
220    /// `self_hash` of the last line written, used as the next line's
221    /// `prev_hash`. Starts as [`CHAIN_HEAD_PREV_HASH`] for a fresh log.
222    last_hash: String,
223    /// Source path, when the sink wraps a real file. `None` for
224    /// in-memory test sinks.
225    #[allow(dead_code)]
226    path: Option<PathBuf>,
227}
228
229/// Initialise the audit sink. Called at most once per process from
230/// [`init_from_config`]; subsequent calls replace the prior sink so
231/// test-only callers can swap targets.
232///
233/// # Errors
234/// - The audit directory cannot be created.
235/// - The audit log file cannot be opened in append mode.
236/// - Reading the existing chain tail (to seed `last_hash`) fails.
237pub fn init(path: &Path, redact_content: bool, append_only_hint: bool) -> Result<()> {
238    if let Some(parent) = path.parent()
239        && !parent.as_os_str().is_empty()
240    {
241        std::fs::create_dir_all(parent)
242            .with_context(|| format!("creating audit log dir {}", parent.display()))?;
243    }
244
245    // Seed the chain head from the existing tail of the log so a
246    // restart on an existing file continues the chain.
247    let last_hash = match read_chain_tail(path) {
248        Ok(Some(h)) => h,
249        _ => CHAIN_HEAD_PREV_HASH.to_string(),
250    };
251
252    let file = OpenOptions::new()
253        .create(true)
254        .append(true)
255        .open(path)
256        .with_context(|| format!("opening audit log {}", path.display()))?;
257
258    if append_only_hint {
259        // Best-effort. Errors here are documented and informational —
260        // the hash chain is the load-bearing tamper-evidence.
261        if let Err(e) = mark_append_only(path) {
262            tracing::warn!(
263                "audit: append-only OS flag could not be set on {} ({e}); \
264                 the hash chain remains the authoritative tamper-evidence",
265                path.display()
266            );
267        }
268    }
269
270    let sink = AuditSink {
271        inner: Mutex::new(SinkInner {
272            writer: Box::new(file),
273            last_hash,
274            path: Some(path.to_path_buf()),
275        }),
276        redact_content,
277    };
278
279    SEQUENCE.store(0, Ordering::SeqCst);
280    if let Ok(mut guard) = SINK.write() {
281        *guard = Some(std::sync::Arc::new(sink));
282    }
283    Ok(())
284}
285
286/// Test-only helper: install an in-memory sink that captures every
287/// emitted line into the supplied `Arc<Mutex<Vec<u8>>>`. Bypasses the
288/// filesystem entirely so tests run in any sandbox.
289#[cfg(test)]
290pub fn init_for_test(buf: std::sync::Arc<Mutex<Vec<u8>>>) {
291    struct VecWriter(std::sync::Arc<Mutex<Vec<u8>>>);
292    impl Write for VecWriter {
293        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
294            self.0
295                .lock()
296                .expect("test sink poisoned")
297                .extend_from_slice(data);
298            Ok(data.len())
299        }
300        fn flush(&mut self) -> std::io::Result<()> {
301            Ok(())
302        }
303    }
304    let sink = AuditSink {
305        inner: Mutex::new(SinkInner {
306            writer: Box::new(VecWriter(buf)),
307            last_hash: CHAIN_HEAD_PREV_HASH.to_string(),
308            path: None,
309        }),
310        redact_content: true,
311    };
312    SEQUENCE.store(0, Ordering::SeqCst);
313    if let Ok(mut guard) = SINK.write() {
314        *guard = Some(std::sync::Arc::new(sink));
315    }
316}
317
318/// Test-only helper to remove the active sink so subsequent emissions
319/// no-op.
320#[cfg(test)]
321pub fn shutdown_for_test() {
322    if let Ok(mut guard) = SINK.write() {
323        *guard = None;
324    }
325    SEQUENCE.store(0, Ordering::SeqCst);
326}
327
328/// Read the last `self_hash` from an existing audit log. Returns
329/// `Ok(None)` when the file is empty or doesn't exist; returns the
330/// `self_hash` of the last well-formed line otherwise. A malformed
331/// trailing line counts as "empty" — emission seeds a fresh chain
332/// head, and `audit verify` will surface the corruption.
333fn read_chain_tail(path: &Path) -> Result<Option<String>> {
334    if !path.exists() {
335        return Ok(None);
336    }
337    let file = File::open(path)?;
338    let reader = BufReader::new(file);
339    let mut last: Option<String> = None;
340    for line in reader.lines() {
341        let line = line?;
342        if line.trim().is_empty() {
343            continue;
344        }
345        if let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) {
346            last = Some(ev.self_hash);
347        }
348    }
349    Ok(last)
350}
351
352/// Whether the audit subsystem is currently enabled. Cheap.
353#[must_use]
354pub fn is_enabled() -> bool {
355    SINK.read().map(|g| g.is_some()).unwrap_or(false)
356}
357
358// ---------------------------------------------------------------------------
359// Hashing — stable canonical form so emit + verify agree byte-for-byte.
360// ---------------------------------------------------------------------------
361
362/// Compute the canonical hash for an event. Hashes the same JSON the
363/// emitter writes to disk EXCEPT with `self_hash` set to the empty
364/// string sentinel — this lets `audit verify` recompute it from the
365/// stored line by zeroing the same field.
366fn compute_self_hash(ev: &AuditEvent) -> String {
367    let canonical = canonical_json_for_hash(ev);
368    let mut hasher = Sha256::new();
369    hasher.update(canonical.as_bytes());
370    hex_encode(&hasher.finalize())
371}
372
373/// Serialize an event into the canonical pre-hash form: serde_json
374/// representation with `self_hash` zeroed. The `prev_hash` is part of
375/// the hashed input — that's exactly the linkage that makes the chain
376/// tamper-evident.
377fn canonical_json_for_hash(ev: &AuditEvent) -> String {
378    let mut clone = ev.clone();
379    clone.self_hash.clear();
380    serde_json::to_string(&clone).expect("AuditEvent always serializes")
381}
382
383fn hex_encode(bytes: &[u8]) -> String {
384    static HEX: &[u8; 16] = b"0123456789abcdef";
385    let mut out = String::with_capacity(bytes.len() * 2);
386    for b in bytes {
387        out.push(HEX[(b >> 4) as usize] as char);
388        out.push(HEX[(b & 0x0f) as usize] as char);
389    }
390    out
391}
392
393// ---------------------------------------------------------------------------
394// Emission API — the surface the rest of the binary calls.
395// ---------------------------------------------------------------------------
396
397/// Builder for an audit event. Most call sites use one of the
398/// convenience helpers ([`emit_store`], [`emit_recall`], etc.) but the
399/// builder is public so unusual flows (consolidate-many, deferred
400/// import) can fill in custom targets.
401#[derive(Debug, Clone)]
402pub struct EventBuilder {
403    pub action: AuditAction,
404    pub actor: AuditActor,
405    pub target: AuditTarget,
406    pub outcome: AuditOutcome,
407    pub auth: Option<AuditAuth>,
408    pub session_id: Option<String>,
409    pub request_id: Option<String>,
410    pub error: Option<String>,
411}
412
413impl EventBuilder {
414    /// Build a default-shaped event for `action`. Caller fills in the
415    /// remaining fields.
416    #[must_use]
417    pub fn new(action: AuditAction, actor: AuditActor, target: AuditTarget) -> Self {
418        Self {
419            action,
420            actor,
421            target,
422            outcome: AuditOutcome::Allow,
423            auth: None,
424            session_id: None,
425            request_id: None,
426            error: None,
427        }
428    }
429
430    /// Override outcome (default = Allow).
431    #[must_use]
432    pub fn outcome(mut self, outcome: AuditOutcome) -> Self {
433        self.outcome = outcome;
434        self
435    }
436
437    /// Set the error string. Caps at 256 chars and strips newlines so a
438    /// runaway error message can't leak content or break the log line.
439    #[must_use]
440    pub fn error(mut self, msg: impl Into<String>) -> Self {
441        self.error = Some(sanitize_field(&msg.into(), 256));
442        self.outcome = AuditOutcome::Error;
443        self
444    }
445
446    #[must_use]
447    pub fn auth(mut self, auth: AuditAuth) -> Self {
448        self.auth = Some(auth);
449        self
450    }
451
452    #[must_use]
453    pub fn request_id(mut self, id: impl Into<String>) -> Self {
454        self.request_id = Some(id.into());
455        self
456    }
457}
458
459/// Write an event to the configured sink. No-op when audit is disabled.
460/// Failures are logged via `tracing::error!` and dropped — audit is
461/// **never** allowed to fail a memory operation.
462pub fn emit(builder: EventBuilder) {
463    if let Err(e) = try_emit(builder) {
464        tracing::error!("audit: emission failed: {e}");
465    }
466}
467
468/// Inner emission with proper `Result` so tests can assert directly on
469/// the writer. `emit` swallows errors so production never blocks.
470fn try_emit(builder: EventBuilder) -> Result<()> {
471    let sink = {
472        let guard = SINK
473            .read()
474            .map_err(|_| anyhow!("audit sink rwlock poisoned"))?;
475        match guard.as_ref() {
476            Some(s) => s.clone(),
477            None => return Ok(()),
478        }
479    };
480
481    let mut inner = sink
482        .inner
483        .lock()
484        .map_err(|_| anyhow!("audit sink mutex poisoned"))?;
485
486    let sequence = SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1;
487
488    let mut ev = AuditEvent {
489        schema_version: SCHEMA_VERSION,
490        timestamp: Utc::now().to_rfc3339(),
491        sequence,
492        actor: builder.actor,
493        action: builder.action,
494        target: AuditTarget {
495            memory_id: sanitize_field(&builder.target.memory_id, 128),
496            namespace: sanitize_field(&builder.target.namespace, 128),
497            title: builder.target.title.map(|t| sanitize_field(&t, 200)),
498            tier: builder.target.tier,
499            scope: builder.target.scope,
500        },
501        outcome: builder.outcome,
502        auth: builder.auth,
503        session_id: builder.session_id,
504        request_id: builder.request_id,
505        error: builder.error,
506        prev_hash: inner.last_hash.clone(),
507        self_hash: String::new(),
508    };
509
510    let self_hash = compute_self_hash(&ev);
511    ev.self_hash = self_hash.clone();
512
513    let line = serde_json::to_string(&ev).context("serializing audit event")?;
514    writeln!(inner.writer, "{line}").context("appending audit line")?;
515    inner.writer.flush().ok();
516    inner.last_hash = self_hash;
517    Ok(())
518}
519
520/// Sanitize a field for log emission: strip control chars + newlines
521/// (prevent log injection) and cap to `max_chars` (prevent unbounded
522/// growth from a hostile title or error message).
523fn sanitize_field(s: &str, max_chars: usize) -> String {
524    let cleaned: String = s
525        .chars()
526        .filter(|c| !c.is_control() || *c == '\t')
527        .collect();
528    if cleaned.chars().count() <= max_chars {
529        cleaned
530    } else {
531        cleaned.chars().take(max_chars).collect()
532    }
533}
534
535// ---------------------------------------------------------------------------
536// Convenience helpers.
537// ---------------------------------------------------------------------------
538
539/// Construct an [`AuditActor`] from an agent_id + synthesis source +
540/// optional scope. The synthesis source is informational metadata and
541/// MUST be one of the documented strings in [`AuditActor`].
542#[must_use]
543pub fn actor(
544    agent_id: impl Into<String>,
545    synthesis_source: impl Into<String>,
546    scope: Option<String>,
547) -> AuditActor {
548    AuditActor {
549        agent_id: agent_id.into(),
550        synthesis_source: synthesis_source.into(),
551        scope,
552    }
553}
554
555/// Construct an [`AuditTarget`] for a single memory.
556#[must_use]
557pub fn target_memory(
558    memory_id: impl Into<String>,
559    namespace: impl Into<String>,
560    title: Option<String>,
561    tier: Option<String>,
562    scope: Option<String>,
563) -> AuditTarget {
564    AuditTarget {
565        memory_id: memory_id.into(),
566        namespace: namespace.into(),
567        title,
568        tier,
569        scope,
570    }
571}
572
573/// Construct an [`AuditTarget`] for a multi-row sweep operation.
574#[must_use]
575pub fn target_sweep(namespace: impl Into<String>) -> AuditTarget {
576    AuditTarget {
577        memory_id: "*".to_string(),
578        namespace: namespace.into(),
579        title: None,
580        tier: None,
581        scope: None,
582    }
583}
584
585// ---------------------------------------------------------------------------
586// Verify — the load-bearing tamper-evidence walk.
587// ---------------------------------------------------------------------------
588
589/// Outcome of [`verify_chain`].
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct VerifyReport {
592    pub total_lines: u64,
593    pub first_failure: Option<VerifyFailure>,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct VerifyFailure {
598    pub line_number: u64,
599    pub kind: VerifyFailureKind,
600    pub detail: String,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum VerifyFailureKind {
605    /// Line could not be parsed as an `AuditEvent`.
606    Parse,
607    /// Recomputed `self_hash` did not match the stored value.
608    SelfHash,
609    /// Stored `prev_hash` did not match the prior line's `self_hash`.
610    ChainBreak,
611    /// `sequence` did not increase monotonically.
612    Sequence,
613}
614
615impl VerifyReport {
616    /// Convenience — `Ok(())` when chain is intact, `Err` when not.
617    pub fn into_result(self) -> Result<u64> {
618        if let Some(failure) = self.first_failure {
619            Err(anyhow!(
620                "audit chain verification failed at line {}: {:?} — {}",
621                failure.line_number,
622                failure.kind,
623                failure.detail
624            ))
625        } else {
626            Ok(self.total_lines)
627        }
628    }
629}
630
631/// Walk an audit log file and verify the chain. Returns a structured
632/// report; the binary's `audit verify` subcommand turns this into an
633/// exit code.
634///
635/// # Errors
636/// - The file cannot be opened or read.
637pub fn verify_chain(path: &Path) -> Result<VerifyReport> {
638    let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
639    verify_chain_from_reader(file)
640}
641
642/// Verify a chain from any [`Read`] source. Lets tests run against
643/// in-memory buffers without touching the filesystem.
644pub fn verify_chain_from_reader<R: Read>(reader: R) -> Result<VerifyReport> {
645    let buf = BufReader::new(reader);
646    let mut total: u64 = 0;
647    let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
648    let mut prev_seq: u64 = 0;
649
650    for (idx, line) in buf.lines().enumerate() {
651        let line_no = (idx as u64) + 1;
652        let line = line.with_context(|| format!("reading audit line {line_no}"))?;
653        if line.trim().is_empty() {
654            continue;
655        }
656        total += 1;
657
658        let ev: AuditEvent = match serde_json::from_str(&line) {
659            Ok(e) => e,
660            Err(e) => {
661                return Ok(VerifyReport {
662                    total_lines: total,
663                    first_failure: Some(VerifyFailure {
664                        line_number: line_no,
665                        kind: VerifyFailureKind::Parse,
666                        detail: format!("malformed JSON: {e}"),
667                    }),
668                });
669            }
670        };
671
672        if ev.prev_hash != prev_hash {
673            return Ok(VerifyReport {
674                total_lines: total,
675                first_failure: Some(VerifyFailure {
676                    line_number: line_no,
677                    kind: VerifyFailureKind::ChainBreak,
678                    detail: format!(
679                        "prev_hash mismatch: expected {prev_hash}, got {}",
680                        ev.prev_hash
681                    ),
682                }),
683            });
684        }
685
686        if ev.sequence <= prev_seq && prev_seq != 0 {
687            return Ok(VerifyReport {
688                total_lines: total,
689                first_failure: Some(VerifyFailure {
690                    line_number: line_no,
691                    kind: VerifyFailureKind::Sequence,
692                    detail: format!(
693                        "sequence not monotonic: prior={prev_seq}, this={}",
694                        ev.sequence
695                    ),
696                }),
697            });
698        }
699
700        let recomputed = compute_self_hash(&ev);
701        if recomputed != ev.self_hash {
702            return Ok(VerifyReport {
703                total_lines: total,
704                first_failure: Some(VerifyFailure {
705                    line_number: line_no,
706                    kind: VerifyFailureKind::SelfHash,
707                    detail: format!(
708                        "self_hash mismatch: stored={}, recomputed={}",
709                        ev.self_hash, recomputed
710                    ),
711                }),
712            });
713        }
714
715        prev_hash = ev.self_hash.clone();
716        prev_seq = ev.sequence;
717    }
718
719    Ok(VerifyReport {
720        total_lines: total,
721        first_failure: None,
722    })
723}
724
725// ---------------------------------------------------------------------------
726// Bootstrap — read AppConfig and bring the sink up.
727// ---------------------------------------------------------------------------
728
729/// Initialise the audit sink from a parsed [`crate::config::AuditConfig`].
730/// Returns `Ok(())` whether or not audit is enabled — it is a no-op when
731/// disabled.
732///
733/// # Errors
734/// - The audit directory or file cannot be opened.
735pub fn init_from_config(cfg: &crate::config::AuditConfig) -> Result<()> {
736    if !cfg.enabled.unwrap_or(false) {
737        if let Ok(mut guard) = SINK.write() {
738            *guard = None;
739        }
740        return Ok(());
741    }
742    let resolved_path = resolve_audit_path(cfg);
743    init(
744        &resolved_path,
745        cfg.redact_content.unwrap_or(true),
746        cfg.append_only.unwrap_or(true),
747    )
748}
749
750/// Resolve the audit log file path from the config, honouring the
751/// user-mandated precedence ladder: CLI > env (`AI_MEMORY_AUDIT_DIR`)
752/// > `[audit] path` in config > platform default. Appends `audit.log`
753/// when the resolved path looks like a directory.
754///
755/// Backwards-compatible wrapper that doesn't take a CLI override —
756/// subcommand wiring uses [`resolve_audit_path_with_override`].
757#[must_use]
758pub fn resolve_audit_path(cfg: &crate::config::AuditConfig) -> PathBuf {
759    let resolved = crate::log_paths::resolve_audit_dir(None, cfg.path.as_deref())
760        .map(|r| r.path)
761        .unwrap_or_else(|_| {
762            crate::log_paths::platform_default(crate::log_paths::DirKind::Audit).path
763        });
764    finalize_audit_file(resolved, cfg.path.as_deref())
765}
766
767/// Strict variant: takes an optional `--audit-dir` override, returns
768/// the resolved file path (with `audit.log` appended when the input
769/// resolves to a directory) plus the [`crate::log_paths::PathSource`]
770/// used.
771///
772/// # Errors
773/// - Resolved directory is world-writable.
774pub fn resolve_audit_path_with_override(
775    cli_override: Option<&Path>,
776    cfg: &crate::config::AuditConfig,
777) -> Result<(PathBuf, crate::log_paths::PathSource)> {
778    let r = crate::log_paths::resolve_audit_dir(cli_override, cfg.path.as_deref())?;
779    let final_path = finalize_audit_file(r.path, cfg.path.as_deref());
780    Ok((final_path, r.source))
781}
782
783/// Append `audit.log` when the resolved path is a directory; respect
784/// an explicit file-path the user wrote in config.
785fn finalize_audit_file(p: PathBuf, raw_config: Option<&str>) -> PathBuf {
786    // If the user configured an explicit file path (has a non-empty
787    // extension that isn't a trailing slash), keep it as-is.
788    if let Some(raw) = raw_config
789        && !raw.ends_with('/')
790        && std::path::Path::new(raw).extension().is_some()
791    {
792        return p;
793    }
794    if p.extension().is_none() || p.to_string_lossy().ends_with('/') {
795        p.join("audit.log")
796    } else {
797        p
798    }
799}
800
801pub(crate) fn expand_tilde(raw: &str) -> String {
802    if let Some(rest) = raw.strip_prefix("~/")
803        && let Ok(home) = std::env::var("HOME")
804    {
805        return format!("{home}/{rest}");
806    }
807    raw.to_string()
808}
809
810// ---------------------------------------------------------------------------
811// Append-only OS hint — best effort.
812// ---------------------------------------------------------------------------
813
814/// Apply the platform-appropriate "append-only" file flag. Silent on
815/// non-unix platforms.
816#[cfg(unix)]
817fn mark_append_only(path: &Path) -> Result<()> {
818    use std::ffi::CString;
819    use std::os::unix::ffi::OsStrExt;
820
821    let c_path =
822        CString::new(path.as_os_str().as_bytes()).context("path contains an interior NUL byte")?;
823    #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
824    {
825        // SAFETY: c_path is a NUL-terminated string we own; chflags is
826        // a libc syscall whose only safety obligation is a valid C
827        // string. UF_APPEND is the user-visible append-only flag.
828        let rc = unsafe { libc::chflags(c_path.as_ptr(), libc::UF_APPEND.into()) };
829        if rc != 0 {
830            return Err(anyhow!(
831                "chflags(UF_APPEND) failed: errno={}",
832                std::io::Error::last_os_error()
833            ));
834        }
835        return Ok(());
836    }
837    #[cfg(target_os = "linux")]
838    {
839        // On Linux we'd issue FS_IOC_SETFLAGS with FS_APPEND_FL. The
840        // syscall requires CAP_LINUX_IMMUTABLE on most filesystems and
841        // is filesystem-specific (ext*, xfs, btrfs); refuse silently
842        // on filesystems that don't support it. This is a best-effort
843        // hint — the chain is the load-bearing tamper-evidence.
844        const FS_APPEND_FL: libc::c_int = 0x0000_0020;
845        // FS_IOC_SETFLAGS = _IOW('f', 2, long) = 0x4008_6602 on most
846        // 64-bit Linux ABIs. Hard-coded to avoid pulling in an extra
847        // crate just for the constant.
848        const FS_IOC_SETFLAGS: libc::c_ulong = 0x4008_6602;
849        let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
850        if fd < 0 {
851            return Err(anyhow!(
852                "open(audit log) for ioctl failed: errno={}",
853                std::io::Error::last_os_error()
854            ));
855        }
856        let mut flags: libc::c_int = 0;
857        // SAFETY: fd is a valid file descriptor we just opened; the
858        // ioctl call follows the documented FS_IOC_GETFLAGS / SETFLAGS
859        // protocol.
860        let rc = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
861        if rc == 0 {
862            flags |= FS_APPEND_FL;
863            let rc2 = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
864            unsafe { libc::close(fd) };
865            if rc2 != 0 {
866                return Err(anyhow!(
867                    "ioctl(FS_IOC_SETFLAGS) failed: errno={}",
868                    std::io::Error::last_os_error()
869                ));
870            }
871            return Ok(());
872        }
873        unsafe { libc::close(fd) };
874        Err(anyhow!(
875            "ioctl(FS_IOC_GETFLAGS) failed: errno={}",
876            std::io::Error::last_os_error()
877        ))
878    }
879    #[cfg(not(any(
880        target_os = "macos",
881        target_os = "freebsd",
882        target_os = "openbsd",
883        target_os = "linux"
884    )))]
885    {
886        let _ = c_path;
887        Err(anyhow!(
888            "append-only flag not supported on this unix variant"
889        ))
890    }
891}
892
893#[cfg(not(unix))]
894fn mark_append_only(_path: &Path) -> Result<()> {
895    Err(anyhow!("append-only flag is unix-only"))
896}
897
898// ---------------------------------------------------------------------------
899// Tests.
900// ---------------------------------------------------------------------------
901
902#[cfg(test)]
903mod tests {
904    use super::*;
905
906    fn sample_event(seq: u64, prev: &str) -> AuditEvent {
907        let mut ev = AuditEvent {
908            schema_version: SCHEMA_VERSION,
909            timestamp: "2026-04-30T00:00:00+00:00".to_string(),
910            sequence: seq,
911            actor: actor("ai:test@host:pid-1", "host_fallback", None),
912            action: AuditAction::Store,
913            target: target_memory(
914                format!("mem-{seq}"),
915                "ns-x",
916                Some("title".to_string()),
917                Some("mid".to_string()),
918                None,
919            ),
920            outcome: AuditOutcome::Allow,
921            auth: None,
922            session_id: None,
923            request_id: None,
924            error: None,
925            prev_hash: prev.to_string(),
926            self_hash: String::new(),
927        };
928        ev.self_hash = compute_self_hash(&ev);
929        ev
930    }
931
932    #[test]
933    fn audit_event_round_trips_through_serde() {
934        let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
935        let s = serde_json::to_string(&ev).unwrap();
936        let back: AuditEvent = serde_json::from_str(&s).unwrap();
937        assert_eq!(back, ev);
938        assert_eq!(back.schema_version, SCHEMA_VERSION);
939    }
940
941    #[test]
942    fn audit_chain_links_correctly_for_three_events() {
943        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
944        let e2 = sample_event(2, &e1.self_hash);
945        let e3 = sample_event(3, &e2.self_hash);
946        let mut buf = String::new();
947        for ev in [&e1, &e2, &e3] {
948            buf.push_str(&serde_json::to_string(ev).unwrap());
949            buf.push('\n');
950        }
951        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
952        assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
953        assert_eq!(report.total_lines, 3);
954    }
955
956    #[test]
957    fn audit_verify_detects_tampered_line() {
958        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
959        let mut e2 = sample_event(2, &e1.self_hash);
960        // Tamper: swap the title without recomputing self_hash.
961        e2.target.title = Some("EVIL".to_string());
962        let e3 = sample_event(3, &e2.self_hash);
963        let mut buf = String::new();
964        for ev in [&e1, &e2, &e3] {
965            buf.push_str(&serde_json::to_string(ev).unwrap());
966            buf.push('\n');
967        }
968        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
969        let failure = report.first_failure.expect("tampering must be detected");
970        assert_eq!(failure.line_number, 2);
971        assert!(matches!(failure.kind, VerifyFailureKind::SelfHash));
972    }
973
974    #[test]
975    fn audit_verify_detects_chain_break() {
976        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
977        // Break: e2's prev_hash points at a hash that isn't e1's.
978        let e2 = sample_event(2, "deadbeef");
979        let mut buf = String::new();
980        for ev in [&e1, &e2] {
981            buf.push_str(&serde_json::to_string(ev).unwrap());
982            buf.push('\n');
983        }
984        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
985        let failure = report.first_failure.expect("chain break must be detected");
986        assert!(matches!(failure.kind, VerifyFailureKind::ChainBreak));
987    }
988
989    #[test]
990    fn audit_redacts_content_by_default() {
991        // The schema does not have a `content` field. This test
992        // doubles as a guardrail: if anyone ever adds one to
993        // AuditEvent or AuditTarget, the round-trip assertion below
994        // will surface it.
995        let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
996        let json = serde_json::to_value(&ev).unwrap();
997        assert!(
998            json.get("content").is_none(),
999            "AuditEvent must never carry a content field"
1000        );
1001        assert!(
1002            json["target"].get("content").is_none(),
1003            "AuditTarget must never carry a content field"
1004        );
1005    }
1006
1007    #[test]
1008    fn audit_action_as_str_round_trips() {
1009        for action in [
1010            AuditAction::Recall,
1011            AuditAction::Store,
1012            AuditAction::Update,
1013            AuditAction::Delete,
1014            AuditAction::Link,
1015            AuditAction::Promote,
1016            AuditAction::Forget,
1017            AuditAction::Consolidate,
1018            AuditAction::Export,
1019            AuditAction::Import,
1020            AuditAction::Approve,
1021            AuditAction::Reject,
1022            AuditAction::SessionBoot,
1023        ] {
1024            let s = action.as_str();
1025            // serde rename-all snake_case round-trips through the
1026            // string representation.
1027            let v: serde_json::Value = serde_json::to_value(action).unwrap();
1028            assert_eq!(v.as_str().unwrap(), s);
1029        }
1030    }
1031
1032    #[test]
1033    fn audit_sanitize_strips_newlines() {
1034        let cleaned = sanitize_field("line1\nline2\rline3", 32);
1035        assert!(!cleaned.contains('\n'));
1036        assert!(!cleaned.contains('\r'));
1037    }
1038
1039    #[test]
1040    fn audit_sanitize_caps_length() {
1041        let s = "x".repeat(500);
1042        let cleaned = sanitize_field(&s, 100);
1043        assert_eq!(cleaned.chars().count(), 100);
1044    }
1045
1046    #[test]
1047    fn audit_resolve_path_directory_expands_to_file() {
1048        let cfg = crate::config::AuditConfig {
1049            enabled: Some(true),
1050            path: Some("/tmp/ai-memory/audit/".to_string()),
1051            ..Default::default()
1052        };
1053        let p = resolve_audit_path(&cfg);
1054        assert!(p.ends_with("audit.log"));
1055    }
1056
1057    #[test]
1058    fn audit_resolve_path_explicit_file_kept() {
1059        let cfg = crate::config::AuditConfig {
1060            enabled: Some(true),
1061            path: Some("/var/log/ai-memory/custom.log".to_string()),
1062            ..Default::default()
1063        };
1064        let p = resolve_audit_path(&cfg);
1065        assert_eq!(p, PathBuf::from("/var/log/ai-memory/custom.log"));
1066    }
1067
1068    /// Serialize tests that mutate the process-wide audit sink so
1069    /// concurrent test runners don't stomp on each other. Tests that
1070    /// touch the live SINK should hold this lock for their duration.
1071    fn sink_lock() -> std::sync::MutexGuard<'static, ()> {
1072        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1073        LOCK.get_or_init(|| std::sync::Mutex::new(()))
1074            .lock()
1075            .unwrap_or_else(|p| p.into_inner())
1076    }
1077
1078    /// PR-5 (issue #487) load-bearing integration test. Wire the
1079    /// audit subsystem to an in-memory sink and emit one event per
1080    /// canonical action. Each successful operation MUST produce one
1081    /// line; the chain MUST stay intact across the run.
1082    #[test]
1083    fn audit_emits_at_every_call_site() {
1084        let _g = sink_lock();
1085        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1086        super::init_for_test(buf.clone());
1087
1088        let actions = [
1089            AuditAction::Store,
1090            AuditAction::Recall,
1091            AuditAction::Update,
1092            AuditAction::Delete,
1093            AuditAction::Link,
1094            AuditAction::Promote,
1095            AuditAction::Forget,
1096            AuditAction::Consolidate,
1097            AuditAction::Export,
1098            AuditAction::Import,
1099            AuditAction::Approve,
1100            AuditAction::Reject,
1101            AuditAction::SessionBoot,
1102        ];
1103        for (i, action) in actions.iter().copied().enumerate() {
1104            let id = format!("mem-{i}");
1105            super::emit(EventBuilder::new(
1106                action,
1107                actor("ai:test@host", "explicit", None),
1108                target_memory(id, "ns-x", Some("t".to_string()), None, None),
1109            ));
1110        }
1111
1112        let lines = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1113        let count = lines.lines().filter(|l| !l.is_empty()).count();
1114        assert_eq!(
1115            count,
1116            actions.len(),
1117            "expected one audit line per action, got {count}: {lines}"
1118        );
1119        // Chain MUST be intact across the whole run.
1120        let report = verify_chain_from_reader(lines.as_bytes()).unwrap();
1121        assert!(
1122            report.first_failure.is_none(),
1123            "chain must verify across all call sites; failure: {:?}",
1124            report.first_failure
1125        );
1126        assert_eq!(report.total_lines as usize, actions.len());
1127
1128        super::shutdown_for_test();
1129    }
1130
1131    #[test]
1132    fn audit_emit_is_noop_when_disabled() {
1133        let _g = sink_lock();
1134        super::shutdown_for_test();
1135        // No sink active — emit must not panic and must not produce
1136        // any output anywhere.
1137        super::emit(EventBuilder::new(
1138            AuditAction::Store,
1139            actor("a", "explicit", None),
1140            target_memory("m", "ns", None, None, None),
1141        ));
1142        // is_enabled stays false.
1143        assert!(!super::is_enabled());
1144    }
1145
1146    #[test]
1147    fn audit_compliance_preset_soc2_overrides_retention() {
1148        // The compliance presets are pure config — applying SOC2 with
1149        // `applied = true` propagates the documented retention to the
1150        // top-level config field. This is a unit-test on the merge
1151        // logic, decoupled from disk.
1152        let cfg = crate::config::AuditConfig {
1153            enabled: Some(true),
1154            retention_days: Some(90),
1155            compliance: Some(crate::config::AuditComplianceConfig {
1156                soc2: Some(crate::config::CompliancePreset {
1157                    applied: Some(true),
1158                    retention_days: Some(730),
1159                    redact_content: Some(true),
1160                    attestation_cadence_minutes: Some(60),
1161                    encrypt_at_rest: None,
1162                    pseudonymize_actors: None,
1163                }),
1164                ..Default::default()
1165            }),
1166            ..Default::default()
1167        };
1168        let resolved = cfg.effective_retention_days();
1169        assert_eq!(resolved, 730, "SOC2 preset must override default retention");
1170    }
1171
1172    // ------------------------------------------------------------------
1173    // PR-9e coverage uplift (issue #487): exercise `init`, `read_chain_tail`,
1174    // builder method chains, `init_from_config` enabled+disabled paths,
1175    // `finalize_audit_file`, and the verify Sequence/Parse failure modes.
1176    // ------------------------------------------------------------------
1177
1178    #[test]
1179    fn audit_init_creates_log_file_in_fresh_directory() {
1180        let _g = sink_lock();
1181        let tmp = tempfile::tempdir().unwrap();
1182        let path = tmp.path().join("nested").join("audit.log");
1183        // Directory does not yet exist; init must create it.
1184        super::init(&path, true, false).unwrap();
1185        assert!(path.exists(), "init must create the log file");
1186        assert!(super::is_enabled());
1187        super::shutdown_for_test();
1188    }
1189
1190    #[test]
1191    fn audit_init_seeds_last_hash_from_existing_chain() {
1192        let _g = sink_lock();
1193        let tmp = tempfile::tempdir().unwrap();
1194        let path = tmp.path().join("audit.log");
1195
1196        // Pre-populate with a 2-event chain. We specifically test the
1197        // `read_chain_tail` linkage: the next emitted event's
1198        // `prev_hash` must match the file's last self_hash.
1199        // (The per-process SEQUENCE counter is independent of the
1200        // chain — `init` resets it to 0, so a re-init on an existing
1201        // file legitimately starts numbering at 1 again. Sequence
1202        // continuity is only required *within a single process run*,
1203        // so we verify only the hash linkage here.)
1204        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1205        let e2 = sample_event(2, &e1.self_hash);
1206        let mut body = String::new();
1207        body.push_str(&serde_json::to_string(&e1).unwrap());
1208        body.push('\n');
1209        body.push_str(&serde_json::to_string(&e2).unwrap());
1210        body.push('\n');
1211        std::fs::write(&path, body).unwrap();
1212
1213        // Init points at the existing file — `read_chain_tail` must
1214        // seed `last_hash` from e2.
1215        super::init(&path, true, false).unwrap();
1216
1217        // Emit a third event; its prev_hash should equal e2.self_hash.
1218        super::emit(EventBuilder::new(
1219            AuditAction::Store,
1220            actor("ai:t@h", "explicit", None),
1221            target_memory("m3", "ns-x", Some("t".to_string()), None, None),
1222        ));
1223
1224        let body = std::fs::read_to_string(&path).unwrap();
1225        let third_line = body.lines().nth(2).expect("3rd line");
1226        let parsed: AuditEvent = serde_json::from_str(third_line).unwrap();
1227        assert_eq!(parsed.prev_hash, e2.self_hash, "chain must continue");
1228        super::shutdown_for_test();
1229    }
1230
1231    #[test]
1232    fn audit_init_skips_chain_tail_when_log_corrupted() {
1233        let _g = sink_lock();
1234        let tmp = tempfile::tempdir().unwrap();
1235        let path = tmp.path().join("audit.log");
1236        // File has a malformed trailing line; init must fall back to
1237        // CHAIN_HEAD_PREV_HASH because no well-formed lines exist.
1238        std::fs::write(&path, "{not valid json\n").unwrap();
1239        super::init(&path, true, false).unwrap();
1240        // Emitting a fresh event must seed prev_hash with the chain head.
1241        super::emit(EventBuilder::new(
1242            AuditAction::Store,
1243            actor("a", "explicit", None),
1244            target_memory("m", "ns", None, None, None),
1245        ));
1246        let body = std::fs::read_to_string(&path).unwrap();
1247        let last = body.lines().filter(|l| !l.is_empty()).last().unwrap();
1248        let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1249        assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1250        super::shutdown_for_test();
1251    }
1252
1253    #[test]
1254    fn audit_event_builder_error_outcome() {
1255        let b = EventBuilder::new(
1256            AuditAction::Store,
1257            actor("a", "explicit", None),
1258            target_memory("m", "ns", None, None, None),
1259        )
1260        .error("boom");
1261        assert_eq!(b.outcome, AuditOutcome::Error);
1262        assert_eq!(b.error.as_deref(), Some("boom"));
1263    }
1264
1265    #[test]
1266    fn audit_event_builder_error_caps_long_message() {
1267        let long = "x".repeat(1000);
1268        let b = EventBuilder::new(
1269            AuditAction::Store,
1270            actor("a", "explicit", None),
1271            target_memory("m", "ns", None, None, None),
1272        )
1273        .error(long);
1274        // sanitize_field caps at 256 chars.
1275        assert_eq!(b.error.as_ref().unwrap().chars().count(), 256);
1276    }
1277
1278    #[test]
1279    fn audit_event_builder_outcome_chain() {
1280        let b = EventBuilder::new(
1281            AuditAction::Store,
1282            actor("a", "explicit", None),
1283            target_memory("m", "ns", None, None, None),
1284        )
1285        .outcome(AuditOutcome::Deny);
1286        assert_eq!(b.outcome, AuditOutcome::Deny);
1287    }
1288
1289    #[test]
1290    fn audit_event_builder_auth_and_request_id() {
1291        let auth = AuditAuth {
1292            source_ip: Some("203.0.113.1".to_string()),
1293            mtls_fp: None,
1294            api_key_id_hash: Some("abc".to_string()),
1295        };
1296        let b = EventBuilder::new(
1297            AuditAction::Store,
1298            actor("a", "explicit", None),
1299            target_memory("m", "ns", None, None, None),
1300        )
1301        .auth(auth.clone())
1302        .request_id("req-123");
1303        assert_eq!(b.auth, Some(auth));
1304        assert_eq!(b.request_id.as_deref(), Some("req-123"));
1305    }
1306
1307    #[test]
1308    fn audit_init_from_config_disabled_clears_sink() {
1309        let _g = sink_lock();
1310        // Bring up an in-memory sink first.
1311        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1312        super::init_for_test(buf);
1313        assert!(super::is_enabled());
1314
1315        let cfg = crate::config::AuditConfig {
1316            enabled: Some(false),
1317            ..Default::default()
1318        };
1319        super::init_from_config(&cfg).unwrap();
1320        // Disabled-branch must clear the global sink.
1321        assert!(!super::is_enabled());
1322        super::shutdown_for_test();
1323    }
1324
1325    #[test]
1326    fn audit_init_from_config_enabled_initialises_sink_at_resolved_path() {
1327        let _g = sink_lock();
1328        super::shutdown_for_test();
1329        let tmp = tempfile::tempdir().unwrap();
1330        let path = tmp.path().join("audit.log");
1331        let cfg = crate::config::AuditConfig {
1332            enabled: Some(true),
1333            path: Some(path.to_string_lossy().into_owned()),
1334            redact_content: Some(true),
1335            // Don't try to apply the OS append-only flag in tests —
1336            // the calling user typically lacks CAP_LINUX_IMMUTABLE
1337            // and we don't want a kernel-level side effect.
1338            append_only: Some(false),
1339            ..Default::default()
1340        };
1341        super::init_from_config(&cfg).unwrap();
1342        assert!(super::is_enabled());
1343        // The configured file must exist on disk after init.
1344        assert!(path.exists(), "audit log file must be created");
1345        super::shutdown_for_test();
1346    }
1347
1348    #[test]
1349    fn audit_finalize_audit_file_keeps_explicit_file_path() {
1350        let cfg = crate::config::AuditConfig {
1351            enabled: Some(true),
1352            path: Some("/var/log/ai-memory/x.log".to_string()),
1353            ..Default::default()
1354        };
1355        let p = resolve_audit_path(&cfg);
1356        // Explicit file path must be preserved (not appended with audit.log).
1357        assert_eq!(p, PathBuf::from("/var/log/ai-memory/x.log"));
1358    }
1359
1360    #[test]
1361    fn audit_finalize_audit_file_appends_audit_log_for_dir_path() {
1362        let cfg = crate::config::AuditConfig {
1363            enabled: Some(true),
1364            path: Some("/var/log/ai-memory/".to_string()),
1365            ..Default::default()
1366        };
1367        let p = resolve_audit_path(&cfg);
1368        assert!(p.ends_with("audit.log"));
1369    }
1370
1371    #[test]
1372    fn audit_finalize_audit_file_appends_audit_log_for_extension_less_path() {
1373        // No trailing slash and no extension: treat as dir, append audit.log.
1374        let cfg = crate::config::AuditConfig {
1375            enabled: Some(true),
1376            path: Some("/var/log/aim_audit_dir".to_string()),
1377            ..Default::default()
1378        };
1379        let p = resolve_audit_path(&cfg);
1380        assert!(p.ends_with("audit.log"));
1381    }
1382
1383    #[test]
1384    fn audit_verify_detects_sequence_regression() {
1385        // Build a chain with a non-monotonic sequence to hit the
1386        // VerifyFailureKind::Sequence branch.
1387        let e1 = sample_event(5, CHAIN_HEAD_PREV_HASH);
1388        // e2 has sequence == e1's sequence (not strictly greater).
1389        let e2 = sample_event(5, &e1.self_hash);
1390        let mut buf = String::new();
1391        for ev in [&e1, &e2] {
1392            buf.push_str(&serde_json::to_string(ev).unwrap());
1393            buf.push('\n');
1394        }
1395        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1396        let failure = report.first_failure.expect("sequence regression");
1397        assert!(matches!(failure.kind, VerifyFailureKind::Sequence));
1398    }
1399
1400    #[test]
1401    fn audit_verify_detects_malformed_json_line() {
1402        // Single garbage line — must surface VerifyFailureKind::Parse.
1403        let buf = "this is not json\n";
1404        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1405        let failure = report.first_failure.expect("parse failure");
1406        assert!(matches!(failure.kind, VerifyFailureKind::Parse));
1407        assert!(failure.detail.contains("malformed JSON"));
1408    }
1409
1410    #[test]
1411    fn audit_verify_skips_blank_lines() {
1412        // Mix blank lines into a valid chain — must verify clean.
1413        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1414        let e2 = sample_event(2, &e1.self_hash);
1415        let buf = format!(
1416            "\n{}\n\n{}\n\n",
1417            serde_json::to_string(&e1).unwrap(),
1418            serde_json::to_string(&e2).unwrap()
1419        );
1420        let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1421        assert!(report.first_failure.is_none());
1422        assert_eq!(report.total_lines, 2);
1423    }
1424
1425    #[test]
1426    fn audit_verify_report_into_result_ok() {
1427        let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1428        let report = verify_chain_from_reader(
1429            format!("{}\n", serde_json::to_string(&e1).unwrap()).as_bytes(),
1430        )
1431        .unwrap();
1432        let n = report.into_result().unwrap();
1433        assert_eq!(n, 1);
1434    }
1435
1436    #[test]
1437    fn audit_verify_report_into_result_err() {
1438        let report = VerifyReport {
1439            total_lines: 5,
1440            first_failure: Some(VerifyFailure {
1441                line_number: 3,
1442                kind: VerifyFailureKind::ChainBreak,
1443                detail: "x".to_string(),
1444            }),
1445        };
1446        let err = report.into_result().unwrap_err();
1447        let msg = format!("{err}");
1448        assert!(msg.contains("audit chain verification failed"));
1449        assert!(msg.contains("line 3"));
1450    }
1451
1452    #[test]
1453    fn audit_emit_records_request_id_and_auth() {
1454        let _g = sink_lock();
1455        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1456        super::init_for_test(buf.clone());
1457        super::emit(
1458            EventBuilder::new(
1459                AuditAction::Store,
1460                actor("a", "explicit", None),
1461                target_memory("m", "ns", None, None, None),
1462            )
1463            .auth(AuditAuth {
1464                source_ip: Some("198.51.100.7".to_string()),
1465                mtls_fp: None,
1466                api_key_id_hash: None,
1467            })
1468            .request_id("trace-abc"),
1469        );
1470        let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1471        assert!(body.contains("\"request_id\":\"trace-abc\""), "got: {body}");
1472        assert!(body.contains("198.51.100.7"));
1473        super::shutdown_for_test();
1474    }
1475
1476    #[test]
1477    fn audit_emit_records_error_outcome() {
1478        let _g = sink_lock();
1479        let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1480        super::init_for_test(buf.clone());
1481        super::emit(
1482            EventBuilder::new(
1483                AuditAction::Store,
1484                actor("a", "explicit", None),
1485                target_memory("m", "ns", None, None, None),
1486            )
1487            .error("disk full"),
1488        );
1489        let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1490        assert!(body.contains("\"outcome\":\"error\""), "got: {body}");
1491        assert!(body.contains("\"error\":\"disk full\""), "got: {body}");
1492        super::shutdown_for_test();
1493    }
1494
1495    #[test]
1496    fn audit_expand_tilde_passthrough_when_no_tilde() {
1497        // Pure-string helper — should leave non-tilde paths intact.
1498        assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
1499        assert_eq!(super::expand_tilde("rel/path"), "rel/path");
1500    }
1501
1502    #[test]
1503    fn audit_target_sweep_uses_wildcard_id() {
1504        let t = super::target_sweep("ns-y");
1505        assert_eq!(t.memory_id, "*");
1506        assert_eq!(t.namespace, "ns-y");
1507    }
1508
1509    #[test]
1510    fn audit_target_memory_round_trips_optional_fields() {
1511        let t = super::target_memory(
1512            "mem-1",
1513            "ns-x",
1514            Some("title".to_string()),
1515            Some("long".to_string()),
1516            Some("team".to_string()),
1517        );
1518        assert_eq!(t.tier.as_deref(), Some("long"));
1519        assert_eq!(t.scope.as_deref(), Some("team"));
1520    }
1521}