use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::runtime_context::RuntimeContext;
pub(crate) const OP_CONSOLIDATE: &str = "consolidate";
pub const SCHEMA_VERSION: u32 = 1;
pub const CHAIN_HEAD_PREV_HASH: &str =
"0000000000000000000000000000000000000000000000000000000000000000";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditEvent {
pub schema_version: u32,
pub timestamp: String,
pub sequence: u64,
pub actor: AuditActor,
pub action: AuditAction,
pub target: AuditTarget,
pub outcome: AuditOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth: Option<AuditAuth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub prev_hash: String,
pub self_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditActor {
pub agent_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub synthesis_source: String,
}
pub mod synthesis_sources {
pub const EXPLICIT: &str = "explicit";
pub const MCP_CLIENT_INFO: &str = "mcp_client_info";
pub const HOST_FALLBACK: &str = "host_fallback";
pub const HTTP_HEADER: &str = "http_header";
pub const DEFAULT_FALLBACK: &str = "default_fallback";
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditAction {
Recall,
Store,
Update,
Delete,
Link,
Promote,
Forget,
Consolidate,
Export,
Import,
Approve,
Reject,
SessionBoot,
CaptureLag,
}
impl AuditAction {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Recall => "recall",
Self::Store => "store",
Self::Update => "update",
Self::Delete => "delete",
Self::Link => "link",
Self::Promote => "promote",
Self::Forget => "forget",
Self::Consolidate => OP_CONSOLIDATE,
Self::Export => "export",
Self::Import => "import",
Self::Approve => "approve",
Self::Reject => "reject",
Self::SessionBoot => "session_boot",
Self::CaptureLag => "capture_lag",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditTarget {
pub memory_id: String,
pub namespace: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
Allow,
Deny,
Error,
Pending,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditAuth {
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtls_fp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_id_hash: Option<String>,
}
pub struct AuditSink {
inner: Mutex<SinkInner>,
#[allow(dead_code)]
redact_content: bool,
}
impl std::fmt::Debug for AuditSink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditSink")
.field("redact_content", &self.redact_content)
.finish_non_exhaustive()
}
}
struct SinkInner {
writer: Box<dyn Write + Send>,
last_hash: String,
#[allow(dead_code)]
path: Option<PathBuf>,
}
pub fn init(path: &Path, redact_content: bool, append_only_hint: bool) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating audit log dir {}", parent.display()))?;
}
let (last_hash, last_sequence) = match read_chain_tail(path) {
Ok(Some((hash, seq))) => (hash, seq),
_ => (CHAIN_HEAD_PREV_HASH.to_string(), 0),
};
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening audit log {}", path.display()))?;
if append_only_hint {
if let Err(e) = mark_append_only(path) {
tracing::warn!(
"audit: append-only OS flag could not be set on {} ({e}); \
the hash chain remains the authoritative tamper-evidence",
path.display()
);
}
}
let sink = AuditSink {
inner: Mutex::new(SinkInner {
writer: Box::new(file),
last_hash,
path: Some(path.to_path_buf()),
}),
redact_content,
};
let audit = &RuntimeContext::global().audit;
audit.sequence.store(last_sequence, Ordering::SeqCst);
if let Ok(mut guard) = audit.sink.write() {
*guard = Some(std::sync::Arc::new(sink));
}
Ok(())
}
#[cfg(test)]
pub fn init_for_test(buf: std::sync::Arc<Mutex<Vec<u8>>>) {
struct VecWriter(std::sync::Arc<Mutex<Vec<u8>>>);
impl Write for VecWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.0
.lock()
.expect("test sink poisoned")
.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let sink = AuditSink {
inner: Mutex::new(SinkInner {
writer: Box::new(VecWriter(buf)),
last_hash: CHAIN_HEAD_PREV_HASH.to_string(),
path: None,
}),
redact_content: true,
};
let audit = &RuntimeContext::global().audit;
audit.sequence.store(0, Ordering::SeqCst);
if let Ok(mut guard) = audit.sink.write() {
*guard = Some(std::sync::Arc::new(sink));
}
}
#[cfg(test)]
pub(crate) fn sink_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
#[cfg(test)]
pub fn shutdown_for_test() {
let audit = &RuntimeContext::global().audit;
if let Ok(mut guard) = audit.sink.write() {
*guard = None;
}
audit.sequence.store(0, Ordering::SeqCst);
}
fn read_chain_tail(path: &Path) -> Result<Option<(String, u64)>> {
if !path.exists() {
return Ok(None);
}
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut last: Option<(String, u64)> = None;
let mut prior_seq: Option<u64> = None;
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) {
if let Some(prev) = prior_seq
&& prev >= ev.sequence
{
tracing::warn!(
target: "ai_memory::audit",
prior_seq = prev,
this_seq = ev.sequence,
path = %path.display(),
"audit: out-of-order sequence number detected on init scan \
(prior {prev} >= this {this}). Hash-chain integrity is the \
authoritative tamper signal; verify with `ai-memory audit verify`.",
prev = prev,
this = ev.sequence
);
}
prior_seq = Some(ev.sequence);
last = Some((ev.self_hash, ev.sequence));
}
}
Ok(last)
}
#[must_use]
pub fn is_enabled() -> bool {
RuntimeContext::global()
.audit
.sink
.read()
.map(|g| g.is_some())
.unwrap_or(false)
}
fn compute_self_hash(ev: &AuditEvent) -> String {
let canonical = canonical_json_for_hash(ev);
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex_encode(&hasher.finalize())
}
fn canonical_json_for_hash(ev: &AuditEvent) -> String {
let mut clone = ev.clone();
clone.self_hash.clear();
serde_json::to_string(&clone).expect("AuditEvent always serializes")
}
fn hex_encode(bytes: &[u8]) -> String {
static HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[derive(Debug, Clone)]
pub struct EventBuilder {
pub action: AuditAction,
pub actor: AuditActor,
pub target: AuditTarget,
pub outcome: AuditOutcome,
pub auth: Option<AuditAuth>,
pub session_id: Option<String>,
pub request_id: Option<String>,
pub error: Option<String>,
}
impl EventBuilder {
#[must_use]
pub fn new(action: AuditAction, actor: AuditActor, target: AuditTarget) -> Self {
Self {
action,
actor,
target,
outcome: AuditOutcome::Allow,
auth: None,
session_id: None,
request_id: None,
error: None,
}
}
#[must_use]
pub fn outcome(mut self, outcome: AuditOutcome) -> Self {
self.outcome = outcome;
self
}
#[must_use]
pub fn error(mut self, msg: impl Into<String>) -> Self {
self.error = Some(sanitize_field(&msg.into(), 256));
self.outcome = AuditOutcome::Error;
self
}
#[must_use]
pub fn auth(mut self, auth: AuditAuth) -> Self {
self.auth = Some(auth);
self
}
#[must_use]
pub fn request_id(mut self, id: impl Into<String>) -> Self {
self.request_id = Some(id.into());
self
}
}
pub fn emit(builder: EventBuilder) {
if let Err(e) = try_emit(builder) {
tracing::error!("audit: emission failed: {e}");
}
}
fn try_emit(builder: EventBuilder) -> Result<()> {
let audit = &RuntimeContext::global().audit;
let sink = {
let guard = audit
.sink
.read()
.map_err(|_| anyhow!("audit sink rwlock poisoned"))?;
match guard.as_ref() {
Some(s) => s.clone(),
None => return Ok(()),
}
};
let mut inner = sink
.inner
.lock()
.map_err(|_| anyhow!("audit sink mutex poisoned"))?;
let sequence = audit.sequence.fetch_add(1, Ordering::SeqCst) + 1;
let mut ev = AuditEvent {
schema_version: SCHEMA_VERSION,
timestamp: Utc::now().to_rfc3339(),
sequence,
actor: builder.actor,
action: builder.action,
target: AuditTarget {
memory_id: sanitize_field(&builder.target.memory_id, 128),
namespace: sanitize_field(&builder.target.namespace, 128),
title: builder.target.title.map(|t| sanitize_field(&t, 200)),
tier: builder.target.tier,
scope: builder.target.scope,
},
outcome: builder.outcome,
auth: builder.auth,
session_id: builder.session_id,
request_id: builder.request_id,
error: builder.error,
prev_hash: inner.last_hash.clone(),
self_hash: String::new(),
};
let self_hash = compute_self_hash(&ev);
ev.self_hash = self_hash.clone();
let line = serde_json::to_string(&ev).context("serializing audit event")?;
writeln!(inner.writer, "{line}").context("appending audit line")?;
inner.writer.flush().ok();
inner.last_hash = self_hash;
Ok(())
}
fn sanitize_field(s: &str, max_chars: usize) -> String {
let cleaned: String = s
.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
if cleaned.chars().count() <= max_chars {
cleaned
} else {
cleaned.chars().take(max_chars).collect()
}
}
#[must_use]
pub fn actor(
agent_id: impl Into<String>,
synthesis_source: impl Into<String>,
scope: Option<String>,
) -> AuditActor {
AuditActor {
agent_id: agent_id.into(),
synthesis_source: synthesis_source.into(),
scope,
}
}
#[must_use]
pub fn target_memory(
memory_id: impl Into<String>,
namespace: impl Into<String>,
title: Option<String>,
tier: Option<String>,
scope: Option<String>,
) -> AuditTarget {
AuditTarget {
memory_id: memory_id.into(),
namespace: namespace.into(),
title,
tier,
scope,
}
}
#[must_use]
pub fn target_sweep(namespace: impl Into<String>) -> AuditTarget {
AuditTarget {
memory_id: "*".to_string(),
namespace: namespace.into(),
title: None,
tier: None,
scope: None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyReport {
pub total_lines: u64,
pub first_failure: Option<VerifyFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifyFailure {
pub line_number: u64,
pub kind: VerifyFailureKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyFailureKind {
Parse,
SelfHash,
ChainBreak,
Sequence,
}
impl VerifyReport {
pub fn into_result(self) -> Result<u64> {
if let Some(failure) = self.first_failure {
Err(anyhow!(
"audit chain verification failed at line {}: {:?} — {}",
failure.line_number,
failure.kind,
failure.detail
))
} else {
Ok(self.total_lines)
}
}
}
pub fn verify_chain(path: &Path) -> Result<VerifyReport> {
let file = File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
verify_chain_from_reader(file)
}
pub fn verify_chain_from_reader<R: Read>(reader: R) -> Result<VerifyReport> {
let buf = BufReader::new(reader);
let mut total: u64 = 0;
let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
let mut prev_seq: u64 = 0;
for (idx, line) in buf.lines().enumerate() {
let line_no = (idx as u64) + 1;
let line = line.with_context(|| format!("reading audit line {line_no}"))?;
if line.trim().is_empty() {
continue;
}
total += 1;
let ev: AuditEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(e) => {
return Ok(VerifyReport {
total_lines: total,
first_failure: Some(VerifyFailure {
line_number: line_no,
kind: VerifyFailureKind::Parse,
detail: format!("malformed JSON: {e}"),
}),
});
}
};
if ev.prev_hash != prev_hash {
return Ok(VerifyReport {
total_lines: total,
first_failure: Some(VerifyFailure {
line_number: line_no,
kind: VerifyFailureKind::ChainBreak,
detail: format!(
"prev_hash mismatch: expected {prev_hash}, got {}",
ev.prev_hash
),
}),
});
}
if ev.sequence <= prev_seq && prev_seq != 0 {
return Ok(VerifyReport {
total_lines: total,
first_failure: Some(VerifyFailure {
line_number: line_no,
kind: VerifyFailureKind::Sequence,
detail: format!(
"sequence not monotonic: prior={prev_seq}, this={}",
ev.sequence
),
}),
});
}
let recomputed = compute_self_hash(&ev);
if recomputed != ev.self_hash {
return Ok(VerifyReport {
total_lines: total,
first_failure: Some(VerifyFailure {
line_number: line_no,
kind: VerifyFailureKind::SelfHash,
detail: format!(
"self_hash mismatch: stored={}, recomputed={}",
ev.self_hash, recomputed
),
}),
});
}
prev_hash = ev.self_hash.clone();
prev_seq = ev.sequence;
}
Ok(VerifyReport {
total_lines: total,
first_failure: None,
})
}
pub fn init_from_config(cfg: &crate::config::AuditConfig) -> Result<()> {
if !cfg.enabled.unwrap_or(false) {
if let Ok(mut guard) = RuntimeContext::global().audit.sink.write() {
*guard = None;
}
return Ok(());
}
let resolved_path = resolve_audit_path(cfg);
init(
&resolved_path,
cfg.redact_content.unwrap_or(true),
cfg.append_only.unwrap_or(true),
)
}
#[must_use]
pub fn resolve_audit_path(cfg: &crate::config::AuditConfig) -> PathBuf {
let resolved = crate::log_paths::resolve_audit_dir(None, cfg.path.as_deref())
.map(|r| r.path)
.unwrap_or_else(|_| {
crate::log_paths::platform_default(crate::log_paths::DirKind::Audit).path
});
finalize_audit_file(resolved, cfg.path.as_deref())
}
pub fn resolve_audit_path_with_override(
cli_override: Option<&Path>,
cfg: &crate::config::AuditConfig,
) -> Result<(PathBuf, crate::log_paths::PathSource)> {
let r = crate::log_paths::resolve_audit_dir(cli_override, cfg.path.as_deref())?;
let final_path = finalize_audit_file(r.path, cfg.path.as_deref());
Ok((final_path, r.source))
}
fn finalize_audit_file(p: PathBuf, raw_config: Option<&str>) -> PathBuf {
if let Some(raw) = raw_config
&& !raw.ends_with('/')
&& std::path::Path::new(raw).extension().is_some()
{
return p;
}
if p.extension().is_none() || p.to_string_lossy().ends_with('/') {
p.join("audit.log")
} else {
p
}
}
pub(crate) fn expand_tilde(raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
raw.to_string()
}
#[cfg(unix)]
fn mark_append_only(path: &Path) -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path =
CString::new(path.as_os_str().as_bytes()).context("path contains an interior NUL byte")?;
#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
{
let rc = unsafe { libc::chflags(c_path.as_ptr(), libc::UF_APPEND.into()) };
if rc != 0 {
return Err(anyhow!(
"chflags(UF_APPEND) failed: errno={}",
std::io::Error::last_os_error()
));
}
return Ok(());
}
#[cfg(target_os = "linux")]
{
const FS_APPEND_FL: libc::c_int = 0x0000_0020;
const FS_IOC_SETFLAGS: libc::c_ulong = 0x4008_6602;
let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
if fd < 0 {
return Err(anyhow!(
"open(audit log) for ioctl failed: errno={}",
std::io::Error::last_os_error()
));
}
let mut flags: libc::c_int = 0;
let rc = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
if rc == 0 {
flags |= FS_APPEND_FL;
let rc2 = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
unsafe { libc::close(fd) };
if rc2 != 0 {
return Err(anyhow!(
"ioctl(FS_IOC_SETFLAGS) failed: errno={}",
std::io::Error::last_os_error()
));
}
return Ok(());
}
unsafe { libc::close(fd) };
Err(anyhow!(
"ioctl(FS_IOC_GETFLAGS) failed: errno={}",
std::io::Error::last_os_error()
))
}
#[cfg(not(any(
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "linux"
)))]
{
let _ = c_path;
Err(anyhow!(
"append-only flag not supported on this unix variant"
))
}
}
#[cfg(not(unix))]
fn mark_append_only(_path: &Path) -> Result<()> {
Err(anyhow!("append-only flag is unix-only"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Tier;
fn sample_event(seq: u64, prev: &str) -> AuditEvent {
let mut ev = AuditEvent {
schema_version: SCHEMA_VERSION,
timestamp: "2026-04-30T00:00:00+00:00".to_string(),
sequence: seq,
actor: actor("ai:test@host:pid-1", "host_fallback", None),
action: AuditAction::Store,
target: target_memory(
format!("mem-{seq}"),
"ns-x",
Some("title".to_string()),
Some(Tier::Mid.as_str().to_string()),
None,
),
outcome: AuditOutcome::Allow,
auth: None,
session_id: None,
request_id: None,
error: None,
prev_hash: prev.to_string(),
self_hash: String::new(),
};
ev.self_hash = compute_self_hash(&ev);
ev
}
#[test]
fn audit_event_round_trips_through_serde() {
let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
let s = serde_json::to_string(&ev).unwrap();
let back: AuditEvent = serde_json::from_str(&s).unwrap();
assert_eq!(back, ev);
assert_eq!(back.schema_version, SCHEMA_VERSION);
}
#[test]
fn audit_chain_links_correctly_for_three_events() {
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(2, &e1.self_hash);
let e3 = sample_event(3, &e2.self_hash);
let mut buf = String::new();
for ev in [&e1, &e2, &e3] {
buf.push_str(&serde_json::to_string(ev).unwrap());
buf.push('\n');
}
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
assert_eq!(report.total_lines, 3);
}
#[test]
fn audit_verify_detects_tampered_line() {
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let mut e2 = sample_event(2, &e1.self_hash);
e2.target.title = Some("EVIL".to_string());
let e3 = sample_event(3, &e2.self_hash);
let mut buf = String::new();
for ev in [&e1, &e2, &e3] {
buf.push_str(&serde_json::to_string(ev).unwrap());
buf.push('\n');
}
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
let failure = report.first_failure.expect("tampering must be detected");
assert_eq!(failure.line_number, 2);
assert!(matches!(failure.kind, VerifyFailureKind::SelfHash));
}
#[test]
fn audit_verify_detects_chain_break() {
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(2, "deadbeef");
let mut buf = String::new();
for ev in [&e1, &e2] {
buf.push_str(&serde_json::to_string(ev).unwrap());
buf.push('\n');
}
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
let failure = report.first_failure.expect("chain break must be detected");
assert!(matches!(failure.kind, VerifyFailureKind::ChainBreak));
}
#[test]
fn audit_redacts_content_by_default() {
let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
let json = serde_json::to_value(&ev).unwrap();
assert!(
json.get("content").is_none(),
"AuditEvent must never carry a content field"
);
assert!(
json["target"].get("content").is_none(),
"AuditTarget must never carry a content field"
);
}
#[test]
fn audit_action_as_str_round_trips() {
for action in [
AuditAction::Recall,
AuditAction::Store,
AuditAction::Update,
AuditAction::Delete,
AuditAction::Link,
AuditAction::Promote,
AuditAction::Forget,
AuditAction::Consolidate,
AuditAction::Export,
AuditAction::Import,
AuditAction::Approve,
AuditAction::Reject,
AuditAction::SessionBoot,
] {
let s = action.as_str();
let v: serde_json::Value = serde_json::to_value(action).unwrap();
assert_eq!(v.as_str().unwrap(), s);
}
}
#[test]
fn audit_sanitize_strips_newlines() {
let cleaned = sanitize_field("line1\nline2\rline3", 32);
assert!(!cleaned.contains('\n'));
assert!(!cleaned.contains('\r'));
}
#[test]
fn audit_sanitize_caps_length() {
let s = "x".repeat(500);
let cleaned = sanitize_field(&s, 100);
assert_eq!(cleaned.chars().count(), 100);
}
#[test]
fn audit_resolve_path_directory_expands_to_file() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("/tmp/ai-memory/audit/".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert!(p.ends_with("audit.log"));
}
#[test]
fn audit_resolve_path_explicit_file_kept() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("/var/log/ai-memory/custom.log".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert_eq!(p, PathBuf::from("/var/log/ai-memory/custom.log"));
}
fn sink_lock() -> std::sync::MutexGuard<'static, ()> {
super::sink_test_lock()
}
#[test]
fn audit_emits_at_every_call_site() {
let _g = sink_lock();
let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
super::init_for_test(buf.clone());
let actions = [
AuditAction::Store,
AuditAction::Recall,
AuditAction::Update,
AuditAction::Delete,
AuditAction::Link,
AuditAction::Promote,
AuditAction::Forget,
AuditAction::Consolidate,
AuditAction::Export,
AuditAction::Import,
AuditAction::Approve,
AuditAction::Reject,
AuditAction::SessionBoot,
AuditAction::CaptureLag,
];
for (i, action) in actions.iter().copied().enumerate() {
let id = format!("mem-{i}");
super::emit(EventBuilder::new(
action,
actor("ai:test@host", "explicit", None),
target_memory(id, "ns-x", Some("t".to_string()), None, None),
));
}
let lines = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
let count = lines.lines().filter(|l| !l.is_empty()).count();
assert_eq!(
count,
actions.len(),
"expected one audit line per action, got {count}: {lines}"
);
let report = verify_chain_from_reader(lines.as_bytes()).unwrap();
assert!(
report.first_failure.is_none(),
"chain must verify across all call sites; failure: {:?}",
report.first_failure
);
assert_eq!(report.total_lines as usize, actions.len());
super::shutdown_for_test();
}
#[test]
fn audit_emit_is_noop_when_disabled() {
let _g = sink_lock();
super::shutdown_for_test();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
));
assert!(!super::is_enabled());
}
#[test]
fn audit_compliance_preset_soc2_overrides_retention() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
retention_days: Some(90),
compliance: Some(crate::config::AuditComplianceConfig {
soc2: Some(crate::config::CompliancePreset {
applied: Some(true),
retention_days: Some(730),
redact_content: Some(true),
attestation_cadence_minutes: Some(60),
encrypt_at_rest: None,
pseudonymize_actors: None,
}),
..Default::default()
}),
..Default::default()
};
let resolved = cfg.effective_retention_days();
assert_eq!(resolved, 730, "SOC2 preset must override default retention");
}
#[test]
fn audit_init_creates_log_file_in_fresh_directory() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("audit.log");
super::init(&path, true, false).unwrap();
assert!(path.exists(), "init must create the log file");
assert!(super::is_enabled());
super::shutdown_for_test();
}
#[test]
fn audit_init_seeds_last_hash_from_existing_chain() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(2, &e1.self_hash);
let mut body = String::new();
body.push_str(&serde_json::to_string(&e1).unwrap());
body.push('\n');
body.push_str(&serde_json::to_string(&e2).unwrap());
body.push('\n');
std::fs::write(&path, body).unwrap();
super::init(&path, true, false).unwrap();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("ai:t@h", "explicit", None),
target_memory("m3", "ns-x", Some("t".to_string()), None, None),
));
let body = std::fs::read_to_string(&path).unwrap();
let third_line = body.lines().nth(2).expect("3rd line");
let parsed: AuditEvent = serde_json::from_str(third_line).unwrap();
assert_eq!(parsed.prev_hash, e2.self_hash, "chain must continue");
super::shutdown_for_test();
}
#[test]
fn audit_init_seeds_sequence_from_existing_chain_tail() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
super::init(&path, true, false).unwrap();
for i in 0..5 {
super::emit(EventBuilder::new(
AuditAction::Store,
actor("ai:writer", "explicit", None),
target_memory(&format!("m{i}"), "ns", Some(format!("t{i}")), None, None),
));
}
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 5, "phase 1 must emit 5 events");
for (i, line) in lines.iter().enumerate() {
let ev: AuditEvent = serde_json::from_str(line).unwrap();
#[allow(clippy::cast_possible_truncation)]
let expected = (i as u64) + 1;
assert_eq!(
ev.sequence, expected,
"phase 1 event {i} must have sequence {expected}"
);
}
super::shutdown_for_test();
super::init(&path, true, false).unwrap();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("ai:writer", "explicit", None),
target_memory("m6", "ns", Some("t6".to_string()), None, None),
));
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 6, "phase 2 must append a 6th event");
let last: AuditEvent = serde_json::from_str(lines[5]).unwrap();
assert_eq!(
last.sequence, 6,
"F2: post-restart event must continue sequence from disk (got {}, expected 6)",
last.sequence,
);
let fifth: AuditEvent = serde_json::from_str(lines[4]).unwrap();
assert_eq!(
last.prev_hash, fifth.self_hash,
"F2 must not regress hash-chain continuity"
);
super::shutdown_for_test();
}
#[test]
fn audit_init_skips_chain_tail_when_log_corrupted() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
std::fs::write(&path, "{not valid json\n").unwrap();
super::init(&path, true, false).unwrap();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
));
let body = std::fs::read_to_string(&path).unwrap();
let last = body.lines().filter(|l| !l.is_empty()).last().unwrap();
let parsed: AuditEvent = serde_json::from_str(last).unwrap();
assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
super::shutdown_for_test();
}
#[test]
fn audit_init_warns_on_out_of_order_sequence() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
let make_event = |seq: u64| AuditEvent {
schema_version: SCHEMA_VERSION,
timestamp: "2026-05-10T00:00:00Z".to_string(),
sequence: seq,
actor: AuditActor {
agent_id: "ai:test".to_string(),
scope: None,
synthesis_source: "explicit".to_string(),
},
action: AuditAction::Store,
target: AuditTarget {
memory_id: format!("m-seq-{seq}"),
namespace: "ns".to_string(),
title: None,
tier: None,
scope: None,
},
outcome: AuditOutcome::Allow,
auth: None,
session_id: None,
request_id: None,
error: None,
prev_hash: CHAIN_HEAD_PREV_HASH.to_string(),
self_hash: format!("{seq:064x}"),
};
let line_a = serde_json::to_string(&make_event(2)).unwrap();
let line_b = serde_json::to_string(&make_event(1)).unwrap();
std::fs::write(&path, format!("{line_a}\n{line_b}\n")).unwrap();
#[derive(Clone, Default)]
struct WarnSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for WarnSink {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for WarnSink {
type Writer = WarnSink;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
let sink = WarnSink::default();
let buf = sink.0.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.with_writer(sink)
.without_time()
.finish();
tracing::subscriber::with_default(subscriber, || {
super::init(&path, true, false)
.expect("M14: init must succeed despite out-of-order seqs");
});
let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(
captured.contains("out-of-order sequence"),
"M14: expected out-of-order WARN, got: {captured:?}"
);
assert!(
captured.contains("prior 2"),
"M14: WARN must include prior sequence (=2), got: {captured:?}"
);
assert!(
captured.contains("this 1"),
"M14: WARN must include this sequence (=1), got: {captured:?}"
);
super::emit(EventBuilder::new(
AuditAction::Store,
actor("ai:writer", "explicit", None),
target_memory("m-after-warn", "ns", None, None, None),
));
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
3,
"M14: init must accept the file and emit must still work"
);
super::shutdown_for_test();
}
#[test]
fn audit_event_builder_error_outcome() {
let b = EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.error("boom");
assert_eq!(b.outcome, AuditOutcome::Error);
assert_eq!(b.error.as_deref(), Some("boom"));
}
#[test]
fn audit_event_builder_error_caps_long_message() {
let long = "x".repeat(1000);
let b = EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.error(long);
assert_eq!(b.error.as_ref().unwrap().chars().count(), 256);
}
#[test]
fn audit_event_builder_outcome_chain() {
let b = EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.outcome(AuditOutcome::Deny);
assert_eq!(b.outcome, AuditOutcome::Deny);
}
#[test]
fn audit_event_builder_auth_and_request_id() {
let auth = AuditAuth {
source_ip: Some("203.0.113.1".to_string()),
mtls_fp: None,
api_key_id_hash: Some("abc".to_string()),
};
let b = EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.auth(auth.clone())
.request_id("req-123");
assert_eq!(b.auth, Some(auth));
assert_eq!(b.request_id.as_deref(), Some("req-123"));
}
#[test]
fn audit_init_from_config_disabled_clears_sink() {
let _g = sink_lock();
let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
super::init_for_test(buf);
assert!(super::is_enabled());
let cfg = crate::config::AuditConfig {
enabled: Some(false),
..Default::default()
};
super::init_from_config(&cfg).unwrap();
assert!(!super::is_enabled());
super::shutdown_for_test();
}
#[test]
fn audit_init_from_config_enabled_initialises_sink_at_resolved_path() {
let _g = sink_lock();
super::shutdown_for_test();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some(path.to_string_lossy().into_owned()),
redact_content: Some(true),
append_only: Some(false),
..Default::default()
};
super::init_from_config(&cfg).unwrap();
assert!(super::is_enabled());
assert!(path.exists(), "audit log file must be created");
super::shutdown_for_test();
}
#[test]
fn audit_finalize_audit_file_keeps_explicit_file_path() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("/var/log/ai-memory/x.log".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert_eq!(p, PathBuf::from("/var/log/ai-memory/x.log"));
}
#[test]
fn audit_finalize_audit_file_appends_audit_log_for_dir_path() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("/var/log/ai-memory/".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert!(p.ends_with("audit.log"));
}
#[test]
fn audit_finalize_audit_file_appends_audit_log_for_extension_less_path() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("/var/log/aim_audit_dir".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert!(p.ends_with("audit.log"));
}
#[test]
fn audit_verify_detects_sequence_regression() {
let e1 = sample_event(5, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(5, &e1.self_hash);
let mut buf = String::new();
for ev in [&e1, &e2] {
buf.push_str(&serde_json::to_string(ev).unwrap());
buf.push('\n');
}
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
let failure = report.first_failure.expect("sequence regression");
assert!(matches!(failure.kind, VerifyFailureKind::Sequence));
}
#[test]
fn audit_verify_detects_malformed_json_line() {
let buf = "this is not json\n";
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
let failure = report.first_failure.expect("parse failure");
assert!(matches!(failure.kind, VerifyFailureKind::Parse));
assert!(failure.detail.contains("malformed JSON"));
}
#[test]
fn audit_verify_skips_blank_lines() {
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(2, &e1.self_hash);
let buf = format!(
"\n{}\n\n{}\n\n",
serde_json::to_string(&e1).unwrap(),
serde_json::to_string(&e2).unwrap()
);
let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
assert!(report.first_failure.is_none());
assert_eq!(report.total_lines, 2);
}
#[test]
fn audit_verify_report_into_result_ok() {
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let report = verify_chain_from_reader(
format!("{}\n", serde_json::to_string(&e1).unwrap()).as_bytes(),
)
.unwrap();
let n = report.into_result().unwrap();
assert_eq!(n, 1);
}
#[test]
fn audit_verify_report_into_result_err() {
let report = VerifyReport {
total_lines: 5,
first_failure: Some(VerifyFailure {
line_number: 3,
kind: VerifyFailureKind::ChainBreak,
detail: "x".to_string(),
}),
};
let err = report.into_result().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("audit chain verification failed"));
assert!(msg.contains("line 3"));
}
#[test]
fn audit_emit_records_request_id_and_auth() {
let _g = sink_lock();
let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
super::init_for_test(buf.clone());
super::emit(
EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.auth(AuditAuth {
source_ip: Some("198.51.100.7".to_string()),
mtls_fp: None,
api_key_id_hash: None,
})
.request_id("trace-abc"),
);
let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(body.contains("\"request_id\":\"trace-abc\""), "got: {body}");
assert!(body.contains("198.51.100.7"));
super::shutdown_for_test();
}
#[test]
fn audit_emit_records_error_outcome() {
let _g = sink_lock();
let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
super::init_for_test(buf.clone());
super::emit(
EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
)
.error("disk full"),
);
let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(body.contains("\"outcome\":\"error\""), "got: {body}");
assert!(body.contains("\"error\":\"disk full\""), "got: {body}");
super::shutdown_for_test();
}
#[test]
fn audit_expand_tilde_passthrough_when_no_tilde() {
assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
assert_eq!(super::expand_tilde("rel/path"), "rel/path");
}
#[test]
fn audit_target_sweep_uses_wildcard_id() {
let t = super::target_sweep("ns-y");
assert_eq!(t.memory_id, "*");
assert_eq!(t.namespace, "ns-y");
}
#[test]
fn audit_target_memory_round_trips_optional_fields() {
let t = super::target_memory(
"mem-1",
"ns-x",
Some("title".to_string()),
Some(Tier::Long.as_str().to_string()),
Some("team".to_string()),
);
assert_eq!(t.tier.as_deref(), Some(Tier::Long.as_str()));
assert_eq!(t.scope.as_deref(), Some("team"));
}
#[test]
fn expand_tilde_substitutes_home_when_set() {
let out = super::expand_tilde("~/audit/log");
assert!(
out.ends_with("/audit/log") || out == "~/audit/log",
"unexpected output shape: {out}"
);
}
#[test]
fn expand_tilde_no_match_passthrough() {
assert_eq!(super::expand_tilde("~root/etc"), "~root/etc");
assert_eq!(super::expand_tilde("~"), "~");
}
#[test]
fn audit_init_returns_error_when_parent_path_is_a_file() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"i am a file, not a directory").unwrap();
let log_path = blocker.join("nested").join("audit.log");
let err = super::init(&log_path, true, false).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("creating audit log dir") || msg.contains("audit"),
"expected wrapped context, got: {msg}"
);
super::shutdown_for_test();
}
#[test]
fn audit_init_applies_append_only_flag_on_macos() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
std::fs::write(&path, b"").unwrap();
super::init(&path, true, true).expect("init must tolerate flag outcome");
assert!(super::is_enabled());
super::shutdown_for_test();
#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
unsafe {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
if let Ok(c) = CString::new(path.as_os_str().as_bytes()) {
let _ = libc::chflags(c.as_ptr(), 0);
}
}
}
#[test]
fn read_chain_tail_returns_none_for_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("nope.log");
let _g = sink_lock();
super::init(&missing, true, false).unwrap();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
));
let body = std::fs::read_to_string(&missing).unwrap();
let line = body.lines().next().unwrap();
let parsed: AuditEvent = serde_json::from_str(line).unwrap();
assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
super::shutdown_for_test();
}
#[test]
fn read_chain_tail_skips_blank_lines() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("audit.log");
let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
let e2 = sample_event(2, &e1.self_hash);
let body = format!(
"{}\n\n\n{}\n \n",
serde_json::to_string(&e1).unwrap(),
serde_json::to_string(&e2).unwrap(),
);
std::fs::write(&path, body).unwrap();
super::init(&path, true, false).unwrap();
super::emit(EventBuilder::new(
AuditAction::Store,
actor("a", "explicit", None),
target_memory("m", "ns", None, None, None),
));
let full = std::fs::read_to_string(&path).unwrap();
let lines: Vec<_> = full.lines().filter(|l| !l.trim().is_empty()).collect();
let last = lines.last().unwrap();
let parsed: AuditEvent = serde_json::from_str(last).unwrap();
assert_eq!(
parsed.prev_hash, e2.self_hash,
"blank lines must be skipped"
);
super::shutdown_for_test();
}
#[test]
fn verify_chain_open_error_wrapped_with_context() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist.log");
let err = super::verify_chain(&missing).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("opening"), "expected context, got: {msg}");
assert!(msg.contains("does-not-exist.log"), "got: {msg}");
}
#[test]
fn finalize_audit_file_keeps_explicit_extension_path() {
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some("./custom.txt".to_string()),
..Default::default()
};
let p = resolve_audit_path(&cfg);
assert!(
p.to_string_lossy().ends_with(".txt"),
"got: {}",
p.display()
);
}
#[test]
fn finalize_audit_file_keeps_resolved_file_when_no_config_override() {
let p = PathBuf::from("/var/log/aimemory.log");
let out = super::finalize_audit_file(p.clone(), None);
assert_eq!(out, p);
}
#[test]
fn resolve_audit_path_falls_back_to_platform_default_when_resolver_errs() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let www = tmp.path().join("world_writable");
std::fs::create_dir_all(&www).unwrap();
std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
let cfg = crate::config::AuditConfig {
enabled: Some(true),
path: Some(www.to_string_lossy().into_owned()),
..Default::default()
};
let p = super::resolve_audit_path(&cfg);
assert!(
!p.starts_with(&www),
"world-writable dir must not be used; got: {}",
p.display()
);
}
}
#[test]
fn resolve_audit_path_with_override_propagates_world_writable_error() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let www = tmp.path().join("ww");
std::fs::create_dir_all(&www).unwrap();
std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
let cfg = crate::config::AuditConfig::default();
let err = super::resolve_audit_path_with_override(Some(&www), &cfg).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("world-writable"),
"expected world-writable error, got: {msg}"
);
}
}
#[test]
fn init_with_directory_in_place_of_file_returns_open_error() {
let _g = sink_lock();
let tmp = tempfile::tempdir().unwrap();
let err = super::init(tmp.path(), true, false).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("opening audit log"), "got: {msg}");
super::shutdown_for_test();
}
#[test]
fn resolve_audit_path_with_override_returns_source_tag() {
let tmp = tempfile::tempdir().unwrap();
let cfg = crate::config::AuditConfig::default();
let (path, _source) =
super::resolve_audit_path_with_override(Some(tmp.path()), &cfg).unwrap();
assert!(
path.starts_with(tmp.path()),
"expected override-rooted path, got: {}",
path.display()
);
assert!(path.ends_with("audit.log"), "got: {}", path.display());
}
}