use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use fs2::FileExt;
use serde::Serialize;
use crate::verdict::Verdict;
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub timestamp: String,
pub action: String,
pub rule_ids: Vec<String>,
pub command_redacted: String,
pub bypass_requested: bool,
pub bypass_honored: bool,
pub interactive: bool,
pub policy_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
pub tier_reached: u8,
}
pub fn log_verdict(
verdict: &Verdict,
command: &str,
log_path: Option<PathBuf>,
event_id: Option<String>,
) {
if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
return;
}
let path = log_path.or_else(default_log_path);
let path = match path {
Some(p) => p,
None => return,
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
action: format!("{:?}", verdict.action),
rule_ids: verdict
.findings
.iter()
.map(|f| f.rule_id.to_string())
.collect(),
command_redacted: redact_command(command),
bypass_requested: verdict.bypass_requested,
bypass_honored: verdict.bypass_honored,
interactive: verdict.interactive_detected,
policy_path: verdict.policy_path_used.clone(),
event_id,
tier_reached: verdict.tier_reached,
};
let line = match serde_json::to_string(&entry) {
Ok(l) => l,
Err(_) => return,
};
let file = OpenOptions::new().create(true).append(true).open(&path);
let file = match file {
Ok(f) => f,
Err(_) => return,
};
if file.lock_exclusive().is_err() {
return;
}
let mut writer = std::io::BufWriter::new(&file);
let _ = writeln!(writer, "{line}");
let _ = writer.flush();
let _ = file.sync_all();
let _ = fs2::FileExt::unlock(&file);
}
fn default_log_path() -> Option<PathBuf> {
crate::policy::data_dir().map(|d| d.join("log.jsonl"))
}
fn redact_command(cmd: &str) -> String {
let prefix = crate::util::truncate_bytes(cmd, 80);
if prefix.len() == cmd.len() {
cmd.to_string()
} else {
format!("{}[...redacted {} chars]", prefix, cmd.len() - prefix.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verdict::{Action, Verdict};
#[test]
fn test_tirith_log_disabled() {
let dir = tempfile::tempdir().unwrap();
let log_path = dir.path().join("test.jsonl");
std::env::set_var("TIRITH_LOG", "0");
let verdict = Verdict {
action: Action::Allow,
findings: vec![],
tier_reached: 1,
timings_ms: crate::verdict::Timings {
tier0_ms: 0.0,
tier1_ms: 0.0,
tier2_ms: None,
tier3_ms: None,
total_ms: 0.0,
},
bypass_requested: false,
bypass_honored: false,
interactive_detected: false,
policy_path_used: None,
urls_extracted_count: None,
};
log_verdict(&verdict, "test cmd", Some(log_path.clone()), None);
assert!(
!log_path.exists(),
"log file should not be created when TIRITH_LOG=0"
);
std::env::remove_var("TIRITH_LOG");
}
}