use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::state_store::StateStore;
pub type HashDigest = String;
pub type AgentId = String;
#[derive(Debug, Clone)]
pub enum AuditError {
ChainBroken {
seq: u64,
expected: String,
found: String,
},
InvalidTimestamp {
seq: u64,
},
ExportFailed(String),
}
impl std::fmt::Display for AuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditError::ChainBroken {
seq,
expected,
found,
} => {
write!(
f,
"chain broken at seq {}: expected hash '{}', found '{}'",
seq, expected, found
)
}
AuditError::InvalidTimestamp { seq } => {
write!(f, "invalid timestamp at seq {}", seq)
}
AuditError::ExportFailed(msg) => {
write!(f, "export failed: {}", msg)
}
}
}
}
impl std::error::Error for AuditError {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", content = "data")]
pub enum AuditAction {
AgentSpawn {
task_type: String,
},
AgentExit {
reason: String,
},
ToolCall {
tool: String,
args_json: String,
},
ToolResult {
tool: String,
success: bool,
},
MemoryWrite {
entry_id: String,
},
MemoryRead {
entry_id: String,
},
ConfigChange {
key: String,
},
ProgramInstall {
program: String,
version: String,
},
CronTrigger {
job_id: String,
},
GitCommit {
message: String,
},
AccessDenied {
permission: String,
},
Other {
detail: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub seq: u64,
pub timestamp: DateTime<Utc>,
pub actor: AgentId,
pub action: AuditAction,
pub resource: String,
pub prev_hash: HashDigest,
pub hash: HashDigest,
pub metadata: Option<serde_json::Value>,
}
fn compute_entry_hash(
seq: u64,
ts: &DateTime<Utc>,
actor: &str,
action: &AuditAction,
resource: &str,
prev: &str,
) -> HashDigest {
use blake3::Hasher;
let mut h = Hasher::new();
h.update(b"oxios-audit-v1");
h.update(&seq.to_be_bytes());
h.update(ts.to_rfc3339().as_bytes());
h.update(actor.as_bytes());
let action_bytes = serde_json::to_vec(action).unwrap_or_default();
h.update(&action_bytes);
h.update(prev.as_bytes());
h.update(resource.as_bytes());
h.finalize().to_hex().to_string()
}
pub struct AuditTrail {
entries: parking_lot::RwLock<Vec<AuditEntry>>,
seq_counter: AtomicU64,
#[allow(dead_code)]
chain_hasher: parking_lot::Mutex<blake3::Hasher>,
max_entries: usize,
}
impl AuditTrail {
pub fn new(max_entries: usize) -> Self {
Self {
entries: parking_lot::RwLock::new(Vec::new()),
seq_counter: AtomicU64::new(1), chain_hasher: parking_lot::Mutex::new(blake3::Hasher::new()),
max_entries,
}
}
pub fn len(&self) -> usize {
self.entries.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn last_hash(&self) -> HashDigest {
let entries = self.entries.read();
entries
.last()
.map(|e| e.hash.clone())
.unwrap_or_else(|| "genesis".to_string())
}
pub fn append(&self, actor: AgentId, action: AuditAction, resource: String) -> HashDigest {
self.append_with_meta(actor, action, resource, None)
}
pub fn append_with_meta(
&self,
actor: AgentId,
action: AuditAction,
resource: String,
metadata: Option<serde_json::Value>,
) -> HashDigest {
let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst);
let timestamp = Utc::now();
let prev_hash = self.last_hash();
let hash = compute_entry_hash(seq, ×tamp, &actor, &action, &resource, &prev_hash);
let entry = AuditEntry {
seq,
timestamp,
actor,
action,
resource,
prev_hash,
hash,
metadata,
};
let entry_hash = entry.hash.clone();
{
let mut entries = self.entries.write();
entries.push(entry);
if entries.len() > self.max_entries {
let excess = entries.len() - self.max_entries;
entries.drain(0..excess);
if let Some(first) = entries.first_mut() {
first.prev_hash = "pruned".to_string();
}
}
}
entry_hash
}
pub fn verify(&self) -> Result<bool, AuditError> {
let entries = self.entries.read();
let mut prev_hash = "genesis".to_string();
for (i, entry) in entries.iter().enumerate() {
if entry.seq == 0 {
return Err(AuditError::ChainBroken {
seq: 0,
expected: "non-zero sequence".to_string(),
found: "0".to_string(),
});
}
if i == 0 && entry.prev_hash == "pruned" {
prev_hash = entry.hash.clone();
continue;
} else if entry.prev_hash != prev_hash {
return Err(AuditError::ChainBroken {
seq: entry.seq,
expected: prev_hash,
found: entry.prev_hash.clone(),
});
}
let now = Utc::now();
if entry.timestamp > now {
return Err(AuditError::InvalidTimestamp { seq: entry.seq });
}
let computed = compute_entry_hash(
entry.seq,
&entry.timestamp,
&entry.actor,
&entry.action,
&entry.resource,
&entry.prev_hash,
);
if computed != entry.hash {
return Err(AuditError::ChainBroken {
seq: entry.seq,
expected: computed,
found: entry.hash.clone(),
});
}
prev_hash = entry.hash.clone();
}
Ok(true)
}
pub fn entries(&self, from_seq: u64, to_seq: u64) -> Vec<AuditEntry> {
let entries = self.entries.read();
entries
.iter()
.filter(|e| e.seq >= from_seq && e.seq <= to_seq)
.cloned()
.collect()
}
pub fn all_entries(&self) -> Vec<AuditEntry> {
self.entries.read().clone()
}
pub fn by_agent(&self, agent_id: &str) -> Vec<AuditEntry> {
let entries = self.entries.read();
entries
.iter()
.filter(|e| e.actor == agent_id)
.cloned()
.collect()
}
pub fn by_action(&self, action: &AuditAction) -> Vec<AuditEntry> {
let entries = self.entries.read();
entries
.iter()
.filter(|e| &e.action == action)
.cloned()
.collect()
}
pub fn by_action_type(&self, type_name: &str) -> Vec<AuditEntry> {
let entries = self.entries.read();
entries
.iter()
.filter(|e| {
let action_name = match &e.action {
AuditAction::AgentSpawn { .. } => "AgentSpawn",
AuditAction::AgentExit { .. } => "AgentExit",
AuditAction::ToolCall { .. } => "ToolCall",
AuditAction::ToolResult { .. } => "ToolResult",
AuditAction::MemoryWrite { .. } => "MemoryWrite",
AuditAction::MemoryRead { .. } => "MemoryRead",
AuditAction::ConfigChange { .. } => "ConfigChange",
AuditAction::ProgramInstall { .. } => "ProgramInstall",
AuditAction::CronTrigger { .. } => "CronTrigger",
AuditAction::GitCommit { .. } => "GitCommit",
AuditAction::AccessDenied { .. } => "AccessDenied",
AuditAction::Other { .. } => "Other",
};
action_name == type_name
})
.cloned()
.collect()
}
pub fn export_json(&self, from_seq: u64) -> Result<String, AuditError> {
let entries = self.entries.read();
let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.seq >= from_seq).collect();
serde_json::to_string_pretty(&filtered).map_err(|e| AuditError::ExportFailed(e.to_string()))
}
pub fn export_all_json(&self) -> Result<String, AuditError> {
let entries = self.entries.read();
serde_json::to_string_pretty(&*entries).map_err(|e| AuditError::ExportFailed(e.to_string()))
}
pub fn flush(&self, state_store: &StateStore) -> Result<(), AuditError> {
let entries = self.entries.read();
state_store
.save_audit_entries(&entries)
.map_err(|e| AuditError::ExportFailed(e.to_string()))
}
pub fn restore_from(&self, entries: Vec<AuditEntry>) {
if entries.is_empty() {
return;
}
let max_seq = entries.iter().map(|e| e.seq).max().unwrap_or(0);
self.seq_counter.store(max_seq + 1, Ordering::SeqCst);
let mut current = self.entries.write();
*current = entries;
if current.len() > self.max_entries {
let excess = current.len() - self.max_entries;
current.drain(0..excess);
if let Some(first) = current.first_mut() {
first.prev_hash = "pruned".to_string();
}
}
tracing::info!(
restored = current.len(),
next_seq = max_seq + 1,
"Audit trail restored from persistence"
);
}
}
impl Default for AuditTrail {
fn default() -> Self {
Self::new(100_000)
}
}
impl std::fmt::Debug for AuditTrail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditTrail")
.field("entries", &self.len())
.field("seq_counter", &self.seq_counter)
.field("max_entries", &self.max_entries)
.finish()
}
}
use anyhow::Result;
impl StateStore {
pub fn save_audit_entries(&self, entries: &[AuditEntry]) -> Result<()> {
let path = self.audit_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(entries)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn load_audit_entries(&self) -> Result<Vec<AuditEntry>> {
let path = self.audit_path();
if !path.exists() {
return Ok(Vec::new());
}
let json = std::fs::read_to_string(&path)?;
let entries: Vec<AuditEntry> = serde_json::from_str(&json)?;
Ok(entries)
}
fn audit_path(&self) -> std::path::PathBuf {
self.base_path.join("audit").join("trail.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_trail() -> AuditTrail {
AuditTrail::new(1000)
}
#[test]
fn test_append_generates_hash() {
let trail = create_test_trail();
let hash = trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
assert!(!hash.is_empty());
assert_eq!(hash.len(), 64); }
#[test]
fn test_append_increments_seq() {
let trail = create_test_trail();
let h1 = trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
let h2 = trail.append(
"agent-002".to_string(),
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
"/test/resource2".to_string(),
);
assert_ne!(h1, h2);
let entries = trail.all_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].seq, 1);
assert_eq!(entries[1].seq, 2);
}
#[test]
fn test_hash_chain_linked() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::AgentExit {
reason: "done".to_string(),
},
"/test/resource".to_string(),
);
let entries = trail.all_entries();
assert_eq!(entries[0].prev_hash, "genesis");
assert_eq!(entries[1].prev_hash, entries[0].hash);
}
#[test]
fn test_verify_passes_clean_chain() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolResult {
tool: "bash".to_string(),
success: true,
},
"/test/resource".to_string(),
);
assert!(trail.verify().is_ok());
}
#[test]
fn test_verify_detects_tampering() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
"/test/resource".to_string(),
);
{
let mut entries = trail.entries.write();
entries[0].actor = "hacker-001".to_string();
}
let result = trail.verify();
assert!(result.is_err());
match result {
Err(AuditError::ChainBroken { seq, .. }) => {
assert_eq!(seq, 1);
}
_ => panic!("expected ChainBroken error"),
}
}
#[test]
fn test_verify_detects_prev_hash_tampering() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
"/test/resource".to_string(),
);
{
let mut entries = trail.entries.write();
entries[1].prev_hash = "fake-hash".to_string();
}
let result = trail.verify();
assert!(result.is_err());
}
#[test]
fn test_export_json_format() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
let json = trail.export_json(0).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 1);
let entry = &parsed[0];
assert!(entry.get("seq").is_some());
assert!(entry.get("timestamp").is_some());
assert!(entry.get("actor").is_some());
assert!(entry.get("action").is_some());
assert!(entry.get("resource").is_some());
assert!(entry.get("prev_hash").is_some());
assert!(entry.get("hash").is_some());
}
#[test]
fn test_by_agent_query() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-002".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::AgentExit {
reason: "done".to_string(),
},
"/test/resource".to_string(),
);
let agent_001_entries = trail.by_agent("agent-001");
assert_eq!(agent_001_entries.len(), 2);
let agent_002_entries = trail.by_agent("agent-002");
assert_eq!(agent_002_entries.len(), 1);
}
#[test]
fn test_by_action_query() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
"/test/resource".to_string(),
);
trail.append(
"agent-001".to_string(),
AuditAction::ToolCall {
tool: "grep".to_string(),
args_json: "{}".to_string(),
},
"/test/resource".to_string(),
);
let spawn_entries = trail.by_action(&AuditAction::AgentSpawn {
task_type: "test".to_string(),
});
assert_eq!(spawn_entries.len(), 1);
let tool_calls = trail.by_action_type("ToolCall");
assert_eq!(tool_calls.len(), 2);
}
#[test]
fn test_entries_range() {
let trail = create_test_trail();
for i in 0..10 {
trail.append(
"agent-001".to_string(),
AuditAction::Other {
detail: format!("action-{}", i),
},
"/test/resource".to_string(),
);
}
let range = trail.entries(3, 7);
assert_eq!(range.len(), 5);
assert_eq!(range[0].seq, 3);
assert_eq!(range[4].seq, 7);
}
#[test]
fn test_auto_prune() {
let trail = AuditTrail::new(5);
for i in 0..10 {
trail.append(
"agent-001".to_string(),
AuditAction::Other {
detail: format!("action-{}", i),
},
"/test/resource".to_string(),
);
}
assert_eq!(trail.len(), 5);
let entries = trail.all_entries();
assert_eq!(entries[0].seq, 6);
assert_eq!(entries[4].seq, 10);
assert!(trail.verify().is_ok(), "Pruned trail should still verify");
}
#[test]
fn test_append_with_metadata() {
let trail = create_test_trail();
let metadata = serde_json::json!({
"duration_ms": 150,
"memory_mb": 32
});
let hash = trail.append_with_meta(
"agent-001".to_string(),
AuditAction::MemoryWrite {
entry_id: "mem-001".to_string(),
},
"/memory/entries".to_string(),
Some(metadata.clone()),
);
assert!(!hash.is_empty());
let entries = trail.all_entries();
assert!(entries[0].metadata.is_some());
assert_eq!(entries[0].metadata.as_ref().unwrap(), &metadata);
}
#[test]
fn test_genesis_hash() {
let trail = create_test_trail();
trail.append(
"agent-001".to_string(),
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/test/resource".to_string(),
);
let entries = trail.all_entries();
assert_eq!(entries[0].prev_hash, "genesis");
}
#[test]
fn test_deterministic_hash() {
let trail1 = create_test_trail();
let trail2 = create_test_trail();
let action = AuditAction::AgentSpawn {
task_type: "test".to_string(),
};
trail1.append(
"agent-001".to_string(),
action.clone(),
"/test/resource".to_string(),
);
let hash = compute_entry_hash(
1,
&trail1.all_entries()[0].timestamp,
"agent-001",
&action,
"/test/resource",
"genesis",
);
assert_eq!(hash, trail1.all_entries()[0].hash);
}
#[test]
fn test_empty_trail_verify() {
let trail = create_test_trail();
assert!(trail.verify().is_ok());
}
#[test]
fn test_all_action_types() {
let trail = create_test_trail();
let actions = vec![
AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
AuditAction::AgentExit {
reason: "done".to_string(),
},
AuditAction::ToolCall {
tool: "bash".to_string(),
args_json: "{}".to_string(),
},
AuditAction::ToolResult {
tool: "bash".to_string(),
success: true,
},
AuditAction::MemoryWrite {
entry_id: "mem-001".to_string(),
},
AuditAction::MemoryRead {
entry_id: "mem-001".to_string(),
},
AuditAction::ConfigChange {
key: "max_agents".to_string(),
},
AuditAction::ProgramInstall {
program: "test-program".to_string(),
version: "1.0.0".to_string(),
},
AuditAction::CronTrigger {
job_id: "job-001".to_string(),
},
AuditAction::GitCommit {
message: "test commit".to_string(),
},
AuditAction::AccessDenied {
permission: "write".to_string(),
},
AuditAction::Other {
detail: "misc".to_string(),
},
];
for (i, action) in actions.into_iter().enumerate() {
trail.append("agent-001".to_string(), action, format!("/resource/{}", i));
}
assert_eq!(trail.len(), 12);
assert!(trail.verify().is_ok());
}
#[test]
fn test_hash_different_for_different_inputs() {
let ts = Utc::now();
let hash1 = compute_entry_hash(
1,
&ts,
"agent-001",
&AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/resource",
"genesis",
);
let hash2 = compute_entry_hash(
2,
&ts,
"agent-001",
&AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/resource",
"genesis",
);
assert_ne!(hash1, hash2);
let hash3 = compute_entry_hash(
1,
&ts,
"agent-002",
&AuditAction::AgentSpawn {
task_type: "test".to_string(),
},
"/resource",
"genesis",
);
assert_ne!(hash1, hash3);
}
#[test]
fn test_restore_from_empty() {
let trail = create_test_trail();
trail.restore_from(Vec::new());
assert!(trail.is_empty());
assert_eq!(trail.all_entries().len(), 0);
}
#[test]
fn test_restore_from_advances_seq_counter() {
let trail = create_test_trail();
let ts = Utc::now();
let mut entries = Vec::new();
let mut prev = "genesis".to_string();
for i in 1..=5 {
let hash = compute_entry_hash(
i,
&ts,
"agent-001",
&AuditAction::Other {
detail: format!("action-{}", i),
},
"/resource",
&prev,
);
entries.push(AuditEntry {
seq: i,
timestamp: ts,
actor: "agent-001".to_string(),
action: AuditAction::Other {
detail: format!("action-{}", i),
},
resource: "/resource".to_string(),
prev_hash: prev.clone(),
hash: hash.clone(),
metadata: None,
});
prev = hash;
}
trail.restore_from(entries);
assert_eq!(trail.len(), 5);
let new_hash = trail.append(
"agent-001".to_string(),
AuditAction::Other {
detail: "new".to_string(),
},
"/resource".to_string(),
);
assert!(!new_hash.is_empty());
assert_eq!(trail.len(), 6);
let all = trail.all_entries();
assert_eq!(all[5].seq, 6);
}
#[test]
fn test_restore_from_trims_to_max() {
let trail = AuditTrail::new(3);
let ts = Utc::now();
let mut entries = Vec::new();
let mut prev = "genesis".to_string();
for i in 1..=5 {
let hash = compute_entry_hash(
i,
&ts,
"agent-001",
&AuditAction::Other {
detail: format!("action-{}", i),
},
"/resource",
&prev,
);
entries.push(AuditEntry {
seq: i,
timestamp: ts,
actor: "agent-001".to_string(),
action: AuditAction::Other {
detail: format!("action-{}", i),
},
resource: "/resource".to_string(),
prev_hash: prev.clone(),
hash: hash.clone(),
metadata: None,
});
prev = hash;
}
trail.restore_from(entries);
assert_eq!(trail.len(), 3);
let all = trail.all_entries();
assert_eq!(all[0].seq, 3);
assert_eq!(all[2].seq, 5);
assert!(trail.verify().is_ok());
}
}