use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use trusty_mpm_core::overseer::{OverseerContext, OverseerDecision};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditEntry {
pub ts: String,
pub session: String,
pub event: String,
pub tool: Option<String>,
pub decision: String,
pub reason: String,
pub handler: String,
}
impl AuditEntry {
pub fn from_decision(
ctx: &OverseerContext,
event: &str,
decision: &OverseerDecision,
handler: &str,
) -> Self {
Self {
ts: chrono::Utc::now().to_rfc3339(),
session: ctx.tmux_name.clone(),
event: event.to_string(),
tool: ctx.tool_name.clone(),
decision: decision.tag().to_string(),
reason: decision.reason().to_string(),
handler: handler.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct AuditLogger {
path: PathBuf,
}
impl AuditLogger {
pub fn new(logs_dir: &Path) -> Self {
let date = chrono::Utc::now().format("%Y-%m-%d");
let path = logs_dir.join("overseer").join(format!("{date}.jsonl"));
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn log(&self, entry: AuditEntry) {
if let Err(e) = self.try_log(&entry) {
tracing::warn!(
"overseer audit write to {} failed: {e}",
self.path.display()
);
}
}
fn try_log(&self, entry: &AuditEntry) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{line}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm_core::session::SessionId;
fn sample_entry() -> AuditEntry {
AuditEntry {
ts: "2026-05-16T00:00:00Z".into(),
session: "tmpm-test-session".into(),
event: "PreToolUse".into(),
tool: Some("Bash".into()),
decision: "block".into(),
reason: "matched blocklist".into(),
handler: "deterministic".into(),
}
}
#[test]
fn new_resolves_dated_path() {
let dir = tempfile::tempdir().unwrap();
let logger = AuditLogger::new(dir.path());
let path = logger.path();
assert!(path.starts_with(dir.path().join("overseer")));
assert_eq!(path.extension().and_then(|e| e.to_str()), Some("jsonl"));
assert!(!path.exists());
}
#[test]
fn entry_serializes_to_json() {
let json = serde_json::to_string(&sample_entry()).unwrap();
let back: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, sample_entry());
}
#[test]
fn entry_from_context_maps_fields() {
let ctx = OverseerContext::new(SessionId::new(), "tmpm-mapped", Some("Edit".into()), None);
let decision = OverseerDecision::Block {
reason: "danger".into(),
};
let entry = AuditEntry::from_decision(&ctx, "PreToolUse", &decision, "deterministic");
assert_eq!(entry.session, "tmpm-mapped");
assert_eq!(entry.tool.as_deref(), Some("Edit"));
assert_eq!(entry.decision, "block");
assert_eq!(entry.reason, "danger");
assert!(!entry.ts.is_empty());
}
#[test]
fn log_writes_jsonl_line() {
let dir = tempfile::tempdir().unwrap();
let logger = AuditLogger::new(dir.path());
logger.log(sample_entry());
let contents = std::fs::read_to_string(logger.path()).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 1);
let parsed: AuditEntry = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed, sample_entry());
}
#[test]
fn log_appends_multiple_lines() {
let dir = tempfile::tempdir().unwrap();
let logger = AuditLogger::new(dir.path());
for _ in 0..3 {
logger.log(sample_entry());
}
let contents = std::fs::read_to_string(logger.path()).unwrap();
assert_eq!(contents.lines().count(), 3);
for line in contents.lines() {
let _: AuditEntry = serde_json::from_str(line).unwrap();
}
}
}