use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AuditEvent {
SandboxCreated {
name: String,
image: String,
backend: String,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
labels: std::collections::HashMap<String, String>,
},
SandboxStarted {
name: String,
profile: Option<String>,
},
SandboxStopped { name: String },
SandboxRemoved { name: String },
CommandExecuted {
sandbox: String,
command: Vec<String>,
exit_code: Option<i32>,
},
FileWritten { sandbox: String, path: String },
FileRead { sandbox: String, path: String },
SessionAttached { sandbox: String },
PolicyViolation {
sandbox: String,
policy: String,
details: String,
},
SshConnected {
sandbox: String,
host_port: u16,
ssh_user: String,
},
SshDisconnected {
sandbox: String,
duration_secs: u64,
recording: Option<String>,
},
SandboxError { name: String, error: String },
ScheduleTriggered {
schedule_id: String,
schedule_name: String,
method: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub pid: u32,
pub user: Option<String>,
#[serde(flatten)]
pub event: AuditEvent,
}
impl AuditEntry {
pub fn new(event: AuditEvent) -> Self {
Self {
timestamp: Utc::now(),
pid: std::process::id(),
user: std::env::var("USER").ok(),
event,
}
}
}
pub fn default_audit_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agentkernel")
.join("audit.jsonl")
}
pub struct AuditLog {
path: PathBuf,
enabled: bool,
}
impl AuditLog {
pub fn new() -> Self {
let enabled = std::env::var("AGENTKERNEL_AUDIT")
.map(|v| v != "0" && v.to_lowercase() != "false")
.unwrap_or(true);
Self {
path: default_audit_path(),
enabled,
}
}
#[allow(dead_code)]
pub fn with_path(path: PathBuf) -> Self {
Self {
path,
enabled: true,
}
}
pub fn log(&self, event: AuditEvent) -> Result<()> {
if !self.enabled {
return Ok(());
}
let entry = AuditEntry::new(event);
let line = serde_json::to_string(&entry)?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{}", line)?;
Ok(())
}
pub fn read_all(&self) -> Result<Vec<AuditEntry>> {
if !self.path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(&self.path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str(&line) {
Ok(entry) => entries.push(entry),
Err(_) => {
if line.contains("\"ssh_connected\"")
&& let Some(second) = line.rfind("\"user\":")
{
let mut fixed = line.clone();
fixed.replace_range(second..second + 7, "\"ssh_user\":");
if let Ok(entry) = serde_json::from_str(&fixed) {
entries.push(entry);
continue;
}
}
eprintln!("Warning: skipping malformed audit entry");
}
}
}
Ok(entries)
}
pub fn read_by_sandbox(&self, sandbox: &str) -> Result<Vec<AuditEntry>> {
let entries = self.read_all()?;
Ok(entries
.into_iter()
.filter(|e| match &e.event {
AuditEvent::SandboxCreated { name, .. } => name == sandbox,
AuditEvent::SandboxStarted { name, .. } => name == sandbox,
AuditEvent::SandboxStopped { name } => name == sandbox,
AuditEvent::SandboxRemoved { name } => name == sandbox,
AuditEvent::CommandExecuted { sandbox: s, .. } => s == sandbox,
AuditEvent::FileWritten { sandbox: s, .. } => s == sandbox,
AuditEvent::FileRead { sandbox: s, .. } => s == sandbox,
AuditEvent::SessionAttached { sandbox: s } => s == sandbox,
AuditEvent::PolicyViolation { sandbox: s, .. } => s == sandbox,
AuditEvent::SshConnected { sandbox: s, .. } => s == sandbox,
AuditEvent::SshDisconnected { sandbox: s, .. } => s == sandbox,
AuditEvent::SandboxError { name, .. } => name == sandbox,
AuditEvent::ScheduleTriggered { .. } => false,
})
.collect())
}
pub fn read_last(&self, n: usize) -> Result<Vec<AuditEntry>> {
let entries = self.read_all()?;
let start = entries.len().saturating_sub(n);
Ok(entries[start..].to_vec())
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
impl Default for AuditLog {
fn default() -> Self {
Self::new()
}
}
pub fn audit() -> &'static AuditLog {
use std::sync::OnceLock;
static AUDIT: OnceLock<AuditLog> = OnceLock::new();
AUDIT.get_or_init(AuditLog::new)
}
pub fn log_event(event: AuditEvent) {
if let Err(e) = audit().log(event) {
eprintln!("Warning: failed to write audit log: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_audit_entry_serialization() {
let entry = AuditEntry::new(AuditEvent::SandboxCreated {
name: "test".to_string(),
image: "alpine:3.20".to_string(),
backend: "docker".to_string(),
labels: Default::default(),
});
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"type\":\"sandbox_created\""));
assert!(json.contains("\"name\":\"test\""));
assert!(json.contains("\"timestamp\""));
}
#[test]
fn test_audit_log_write_read() {
let dir = tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let log = AuditLog::with_path(path);
log.log(AuditEvent::SandboxCreated {
name: "test1".to_string(),
image: "alpine".to_string(),
backend: "docker".to_string(),
labels: Default::default(),
})
.unwrap();
log.log(AuditEvent::CommandExecuted {
sandbox: "test1".to_string(),
command: vec!["echo".to_string(), "hello".to_string()],
exit_code: Some(0),
})
.unwrap();
let entries = log.read_all().unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_audit_log_filter_by_sandbox() {
let dir = tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let log = AuditLog::with_path(path);
log.log(AuditEvent::SandboxCreated {
name: "test1".to_string(),
image: "alpine".to_string(),
backend: "docker".to_string(),
labels: Default::default(),
})
.unwrap();
log.log(AuditEvent::SandboxCreated {
name: "test2".to_string(),
image: "alpine".to_string(),
backend: "docker".to_string(),
labels: Default::default(),
})
.unwrap();
let filtered = log.read_by_sandbox("test1").unwrap();
assert_eq!(filtered.len(), 1);
}
#[test]
fn test_ssh_connected_event() {
let event = AuditEvent::SshConnected {
sandbox: "test-box".to_string(),
host_port: 2222,
ssh_user: "sandbox".to_string(),
};
let entry = AuditEntry::new(event);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("ssh_connected"));
assert!(json.contains("test-box"));
assert!(json.contains("2222"));
assert!(json.contains("ssh_user"));
let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
if let AuditEvent::SshConnected { ssh_user, .. } = &parsed.event {
assert_eq!(ssh_user, "sandbox");
} else {
panic!("Expected SshConnected event after roundtrip");
}
assert!(parsed.user.is_some());
}
#[test]
fn test_ssh_disconnected_event() {
let event = AuditEvent::SshDisconnected {
sandbox: "test-box".to_string(),
duration_secs: 120,
recording: Some("/tmp/session.cast".to_string()),
};
let entry = AuditEntry::new(event);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("ssh_disconnected"));
assert!(json.contains("120"));
assert!(json.contains("session.cast"));
}
#[test]
fn test_ssh_disconnected_without_recording() {
let event = AuditEvent::SshDisconnected {
sandbox: "test-box".to_string(),
duration_secs: 60,
recording: None,
};
let entry = AuditEntry::new(event);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("ssh_disconnected"));
assert!(!json.contains("session.cast"));
}
}