use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: String,
pub agent_id: String,
pub tool: String,
pub action: Option<String>,
pub input_hash: String,
pub output_tokens: u32,
pub role: String,
pub event_type: AuditEventType,
pub prev_hash: String,
pub entry_hash: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub signature: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
ToolCall,
ToolDenied,
PathJailViolation,
BudgetExceeded,
CrossProjectAccess,
RateLimited,
SecurityViolation,
RoleChanged,
SecretDetected,
AgentRegistered,
AgentSuspended,
AgentResumed,
AgentDecommissioned,
}
pub struct AuditEntryData {
pub agent_id: String,
pub tool: String,
pub action: Option<String>,
pub input_hash: String,
pub output_tokens: u32,
pub role: String,
pub event_type: AuditEventType,
}
pub struct ChainVerifyResult {
pub total_entries: usize,
pub valid: bool,
pub first_invalid_at: Option<usize>,
}
fn trail_path() -> Option<PathBuf> {
let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
let audit_dir = dir.join("audit");
fs::create_dir_all(&audit_dir).ok()?;
Some(audit_dir.join("trail.jsonl"))
}
fn read_last_hash_tail(file: &fs::File) -> String {
use std::io::{Read, Seek, SeekFrom};
const TAIL: i64 = 64 * 1024;
let mut f = file;
let Ok(len) = f.seek(SeekFrom::End(0)) else {
return "genesis".to_string();
};
if len == 0 {
return "genesis".to_string();
}
let start = if (len as i64) > TAIL {
-TAIL
} else {
-(len as i64)
};
if f.seek(SeekFrom::End(start)).is_err() {
return "genesis".to_string();
}
let mut buf = String::new();
if f.read_to_string(&mut buf).is_err() {
return "genesis".to_string();
}
for line in buf.lines().rev() {
if line.trim().is_empty() {
continue;
}
let mut last: Option<String> = None;
for v in serde_json::Deserializer::from_str(line)
.into_iter::<serde_json::Value>()
.flatten()
{
if let Some(h) = v.get("entry_hash").and_then(|h| h.as_str()) {
last = Some(h.to_string());
}
}
if let Some(h) = last {
return h;
}
}
"genesis".to_string()
}
fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(prev_hash.as_bytes());
hasher.update(data_json.as_bytes());
crate::core::agent_identity::hex_encode(&hasher.finalize())
}
pub fn record(data: AuditEntryData) {
use fs2::FileExt;
let Some(path) = trail_path() else { return };
let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(&path)
else {
return;
};
if file.lock_exclusive().is_err() {
return;
}
let prev_hash = read_last_hash_tail(&file);
let partial = serde_json::json!({
"agent_id": data.agent_id,
"tool": data.tool,
"action": data.action,
"input_hash": data.input_hash,
"output_tokens": data.output_tokens,
"role": data.role,
"event_type": data.event_type,
});
let data_json = serde_json::to_string(&partial).unwrap_or_default();
let entry_hash = compute_entry_hash(&prev_hash, &data_json);
let signature = crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
.map(|sig| crate::core::agent_identity::hex_encode(&sig))
.ok();
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
agent_id: data.agent_id,
tool: data.tool,
action: data.action,
input_hash: data.input_hash,
output_tokens: data.output_tokens,
role: data.role,
event_type: data.event_type,
prev_hash,
entry_hash,
signature,
};
if let Ok(line) = serde_json::to_string(&entry) {
let _ = writeln!(file, "{line}");
}
let _ = FileExt::unlock(&file);
}
pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
let Some(path) = trail_path() else {
return Vec::new();
};
let Ok(file) = fs::File::open(&path) else {
return Vec::new();
};
let reader = std::io::BufReader::new(file);
let entries: Vec<AuditEntry> = reader
.lines()
.map_while(Result::ok)
.filter_map(|line| serde_json::from_str(&line).ok())
.collect();
let skip = entries.len().saturating_sub(limit);
entries.into_iter().skip(skip).collect()
}
pub fn verify_chain() -> ChainVerifyResult {
let Some(path) = trail_path() else {
return ChainVerifyResult {
total_entries: 0,
valid: true,
first_invalid_at: None,
};
};
let Ok(file) = fs::File::open(&path) else {
return ChainVerifyResult {
total_entries: 0,
valid: true,
first_invalid_at: None,
};
};
let reader = std::io::BufReader::new(file);
let mut prev_hash = "genesis".to_string();
let mut total = 0usize;
for line in reader.lines().map_while(Result::ok) {
let entry: AuditEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => {
return ChainVerifyResult {
total_entries: total,
valid: false,
first_invalid_at: Some(total),
};
}
};
if entry.prev_hash != prev_hash {
return ChainVerifyResult {
total_entries: total,
valid: false,
first_invalid_at: Some(total),
};
}
let partial = serde_json::json!({
"agent_id": entry.agent_id,
"tool": entry.tool,
"action": entry.action,
"input_hash": entry.input_hash,
"output_tokens": entry.output_tokens,
"role": entry.role,
"event_type": entry.event_type,
});
let data_json = serde_json::to_string(&partial).unwrap_or_default();
let expected = compute_entry_hash(&prev_hash, &data_json);
if entry.entry_hash != expected {
return ChainVerifyResult {
total_entries: total,
valid: false,
first_invalid_at: Some(total),
};
}
prev_hash = entry.entry_hash;
total += 1;
}
ChainVerifyResult {
total_entries: total,
valid: true,
first_invalid_at: None,
}
}
pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
let serialized = serde_json::to_string(args).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
crate::core::agent_identity::hex_encode(&hasher.finalize())
}