1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::PathBuf;
4
5use fs2::FileExt;
6use serde::Serialize;
7
8use crate::verdict::Verdict;
9
10#[derive(Debug, Clone, Serialize)]
12pub struct AuditEntry {
13 pub timestamp: String,
14 pub action: String,
15 pub rule_ids: Vec<String>,
16 pub command_redacted: String,
17 pub bypass_requested: bool,
18 pub bypass_honored: bool,
19 pub interactive: bool,
20 pub policy_path: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub event_id: Option<String>,
23 pub tier_reached: u8,
24}
25
26pub fn log_verdict(
28 verdict: &Verdict,
29 command: &str,
30 log_path: Option<PathBuf>,
31 event_id: Option<String>,
32) {
33 if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
35 return;
36 }
37
38 let path = log_path.or_else(default_log_path);
39 let path = match path {
40 Some(p) => p,
41 None => return,
42 };
43
44 if let Some(parent) = path.parent() {
46 let _ = fs::create_dir_all(parent);
47 }
48
49 let entry = AuditEntry {
50 timestamp: chrono::Utc::now().to_rfc3339(),
51 action: format!("{:?}", verdict.action),
52 rule_ids: verdict
53 .findings
54 .iter()
55 .map(|f| f.rule_id.to_string())
56 .collect(),
57 command_redacted: redact_command(command),
58 bypass_requested: verdict.bypass_requested,
59 bypass_honored: verdict.bypass_honored,
60 interactive: verdict.interactive_detected,
61 policy_path: verdict.policy_path_used.clone(),
62 event_id,
63 tier_reached: verdict.tier_reached,
64 };
65
66 let line = match serde_json::to_string(&entry) {
67 Ok(l) => l,
68 Err(_) => return,
69 };
70
71 let file = OpenOptions::new().create(true).append(true).open(&path);
73
74 let file = match file {
75 Ok(f) => f,
76 Err(_) => return,
77 };
78
79 if file.lock_exclusive().is_err() {
80 return;
81 }
82
83 let mut writer = std::io::BufWriter::new(&file);
84 let _ = writeln!(writer, "{line}");
85 let _ = writer.flush();
86 let _ = file.sync_all();
87 let _ = fs2::FileExt::unlock(&file);
88}
89
90fn default_log_path() -> Option<PathBuf> {
91 crate::policy::data_dir().map(|d| d.join("log.jsonl"))
92}
93
94fn redact_command(cmd: &str) -> String {
95 let prefix = crate::util::truncate_bytes(cmd, 80);
97 if prefix.len() == cmd.len() {
98 cmd.to_string()
99 } else {
100 format!("{}[...redacted {} chars]", prefix, cmd.len() - prefix.len())
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::verdict::{Action, Verdict};
108
109 #[test]
110 fn test_tirith_log_disabled() {
111 let dir = tempfile::tempdir().unwrap();
112 let log_path = dir.path().join("test.jsonl");
113
114 std::env::set_var("TIRITH_LOG", "0");
116
117 let verdict = Verdict {
118 action: Action::Allow,
119 findings: vec![],
120 tier_reached: 1,
121 timings_ms: crate::verdict::Timings {
122 tier0_ms: 0.0,
123 tier1_ms: 0.0,
124 tier2_ms: None,
125 tier3_ms: None,
126 total_ms: 0.0,
127 },
128 bypass_requested: false,
129 bypass_honored: false,
130 interactive_detected: false,
131 policy_path_used: None,
132 urls_extracted_count: None,
133 };
134
135 log_verdict(&verdict, "test cmd", Some(log_path.clone()), None);
136
137 assert!(
139 !log_path.exists(),
140 "log file should not be created when TIRITH_LOG=0"
141 );
142
143 std::env::remove_var("TIRITH_LOG");
145 }
146}