1use std::fs::{self, OpenOptions};
2use std::io::Write;
3#[cfg(unix)]
4use std::os::unix::fs::OpenOptionsExt;
5use std::path::PathBuf;
6
7use fs2::FileExt;
8use serde::Serialize;
9
10use crate::verdict::Verdict;
11
12fn audit_diagnostics_enabled() -> bool {
13 matches!(
14 std::env::var("TIRITH_AUDIT_DEBUG")
15 .ok()
16 .map(|v| v.trim().to_ascii_lowercase())
17 .as_deref(),
18 Some("1" | "true" | "yes")
19 )
20}
21
22pub fn audit_diagnostic(msg: impl AsRef<str>) {
27 if audit_diagnostics_enabled() {
28 eprintln!("{}", msg.as_ref());
29 }
30}
31
32#[derive(Debug, Clone, Serialize)]
34pub struct AuditEntry {
35 pub timestamp: String,
36 pub session_id: String,
37 pub action: String,
38 pub rule_ids: Vec<String>,
39 pub command_redacted: String,
40 pub bypass_requested: bool,
41 pub bypass_honored: bool,
42 pub interactive: bool,
43 pub policy_path: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub event_id: Option<String>,
46 pub tier_reached: u8,
47}
48
49pub fn log_verdict(
54 verdict: &Verdict,
55 command: &str,
56 log_path: Option<PathBuf>,
57 event_id: Option<String>,
58 custom_dlp_patterns: &[String],
59) {
60 if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
62 return;
63 }
64
65 let path = log_path.or_else(default_log_path);
66 let path = match path {
67 Some(p) => p,
68 None => return,
69 };
70
71 if let Some(parent) = path.parent() {
73 if let Err(e) = fs::create_dir_all(parent) {
74 audit_diagnostic(format!(
75 "tirith: audit: cannot create log dir {}: {e}",
76 parent.display()
77 ));
78 return;
79 }
80 }
81
82 let entry = AuditEntry {
83 timestamp: chrono::Utc::now().to_rfc3339(),
84 session_id: crate::session::session_id().to_string(),
85 action: format!("{:?}", verdict.action),
86 rule_ids: verdict
87 .findings
88 .iter()
89 .map(|f| f.rule_id.to_string())
90 .collect(),
91 command_redacted: redact_command(command, custom_dlp_patterns),
92 bypass_requested: verdict.bypass_requested,
93 bypass_honored: verdict.bypass_honored,
94 interactive: verdict.interactive_detected,
95 policy_path: verdict.policy_path_used.clone(),
96 event_id,
97 tier_reached: verdict.tier_reached,
98 };
99
100 let line = match serde_json::to_string(&entry) {
101 Ok(l) => l,
102 Err(e) => {
103 audit_diagnostic(format!("tirith: audit: failed to serialize entry: {e}"));
104 return;
105 }
106 };
107
108 #[cfg(unix)]
110 {
111 match std::fs::symlink_metadata(&path) {
112 Ok(meta) if meta.file_type().is_symlink() => {
113 audit_diagnostic(format!(
114 "tirith: audit: refusing to follow symlink at {}",
115 path.display()
116 ));
117 return;
118 }
119 _ => {}
120 }
121 }
122
123 let mut open_opts = OpenOptions::new();
125 open_opts.create(true).append(true);
126 #[cfg(unix)]
127 {
128 open_opts.mode(0o600);
129 open_opts.custom_flags(libc::O_NOFOLLOW);
130 }
131 let file = open_opts.open(&path);
132
133 let file = match file {
134 Ok(f) => f,
135 Err(e) => {
136 audit_diagnostic(format!(
137 "tirith: audit: cannot open {}: {e}",
138 path.display()
139 ));
140 return;
141 }
142 };
143
144 #[cfg(unix)]
146 {
147 use std::os::unix::fs::PermissionsExt;
148 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
149 }
150
151 if let Err(e) = file.lock_exclusive() {
152 audit_diagnostic(format!(
153 "tirith: audit: cannot lock {}: {e}",
154 path.display()
155 ));
156 return;
157 }
158
159 let mut writer = std::io::BufWriter::new(&file);
160 if let Err(e) = writeln!(writer, "{line}") {
161 audit_diagnostic(format!("tirith: audit: write failed: {e}"));
162 let _ = fs2::FileExt::unlock(&file);
163 return;
164 }
165 if let Err(e) = writer.flush() {
166 audit_diagnostic(format!("tirith: audit: flush failed: {e}"));
167 }
168 if let Err(e) = file.sync_all() {
169 audit_diagnostic(format!("tirith: audit: sync failed: {e}"));
170 }
171 let _ = fs2::FileExt::unlock(&file);
172
173 let server_url = std::env::var("TIRITH_SERVER_URL")
177 .ok()
178 .filter(|s| !s.is_empty());
179 let api_key = std::env::var("TIRITH_API_KEY")
180 .ok()
181 .filter(|s| !s.is_empty());
182 if let (Some(url), Some(key)) = (server_url, api_key) {
183 crate::audit_upload::spool_and_upload(&line, &url, &key, None, None);
184 }
185}
186
187fn default_log_path() -> Option<PathBuf> {
188 crate::policy::data_dir().map(|d| d.join("log.jsonl"))
189}
190
191fn redact_command(cmd: &str, custom_patterns: &[String]) -> String {
192 let dlp_redacted = crate::redact::redact_with_custom(cmd, custom_patterns);
194 let prefix = crate::util::truncate_bytes(&dlp_redacted, 80);
196 if prefix.len() == dlp_redacted.len() {
197 dlp_redacted
198 } else {
199 format!(
200 "{}[...redacted {} bytes]",
201 prefix,
202 dlp_redacted.len() - prefix.len()
203 )
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::verdict::{Action, Verdict};
211
212 #[test]
213 fn test_tirith_log_disabled() {
214 let _guard = crate::TEST_ENV_LOCK
215 .lock()
216 .unwrap_or_else(|e| e.into_inner());
217 let dir = tempfile::tempdir().unwrap();
218 let log_path = dir.path().join("test.jsonl");
219
220 unsafe { std::env::set_var("TIRITH_LOG", "0") };
222
223 let verdict = Verdict {
224 action: Action::Allow,
225 findings: vec![],
226 tier_reached: 1,
227 timings_ms: crate::verdict::Timings {
228 tier0_ms: 0.0,
229 tier1_ms: 0.0,
230 tier2_ms: None,
231 tier3_ms: None,
232 total_ms: 0.0,
233 },
234 bypass_requested: false,
235 bypass_honored: false,
236 interactive_detected: false,
237 policy_path_used: None,
238 urls_extracted_count: None,
239 requires_approval: None,
240 approval_timeout_secs: None,
241 approval_fallback: None,
242 approval_rule: None,
243 approval_description: None,
244 };
245
246 log_verdict(&verdict, "test cmd", Some(log_path.clone()), None, &[]);
247
248 assert!(
250 !log_path.exists(),
251 "log file should not be created when TIRITH_LOG=0"
252 );
253
254 unsafe { std::env::remove_var("TIRITH_LOG") };
256 }
257
258 #[test]
259 fn test_audit_diagnostics_disabled_by_default() {
260 let _guard = crate::TEST_ENV_LOCK
261 .lock()
262 .unwrap_or_else(|e| e.into_inner());
263 unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
264 assert!(!audit_diagnostics_enabled());
265 }
266
267 #[test]
268 fn test_audit_diagnostics_enabled_by_env() {
269 let _guard = crate::TEST_ENV_LOCK
270 .lock()
271 .unwrap_or_else(|e| e.into_inner());
272 unsafe { std::env::set_var("TIRITH_AUDIT_DEBUG", "true") };
273 assert!(audit_diagnostics_enabled());
274 unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
275 }
276
277 #[cfg(unix)]
278 #[test]
279 fn test_audit_log_permissions_0600() {
280 use std::os::unix::fs::PermissionsExt;
281
282 let dir = tempfile::tempdir().unwrap();
285 let log_path = dir.path().join("test_perms.jsonl");
286
287 {
288 use std::io::Write;
289 let mut open_opts = OpenOptions::new();
290 open_opts.create(true).append(true);
291 use std::os::unix::fs::OpenOptionsExt;
292 open_opts.mode(0o600);
293 let mut f = open_opts.open(&log_path).unwrap();
294 writeln!(f, "test").unwrap();
295 }
296
297 let meta = std::fs::metadata(&log_path).unwrap();
298 assert_eq!(
299 meta.permissions().mode() & 0o777,
300 0o600,
301 "audit log should be 0600"
302 );
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn test_remote_audit_upload_spools_when_configured() {
308 let _guard = crate::TEST_ENV_LOCK
309 .lock()
310 .unwrap_or_else(|e| e.into_inner());
311
312 let dir = tempfile::tempdir().unwrap();
313 let log_path = dir.path().join("audit.jsonl");
314 let state_home = dir.path().join("state");
315
316 unsafe { std::env::set_var("TIRITH_SERVER_URL", "http://127.0.0.1") };
318 unsafe { std::env::set_var("TIRITH_API_KEY", "dummy") };
319 unsafe { std::env::set_var("XDG_STATE_HOME", &state_home) };
320 unsafe { std::env::remove_var("TIRITH_LOG") };
321
322 let verdict = Verdict {
323 action: Action::Allow,
324 findings: vec![],
325 tier_reached: 1,
326 timings_ms: crate::verdict::Timings {
327 tier0_ms: 0.0,
328 tier1_ms: 0.0,
329 tier2_ms: None,
330 tier3_ms: None,
331 total_ms: 0.0,
332 },
333 bypass_requested: false,
334 bypass_honored: false,
335 interactive_detected: false,
336 policy_path_used: None,
337 urls_extracted_count: None,
338 requires_approval: None,
339 approval_timeout_secs: None,
340 approval_fallback: None,
341 approval_rule: None,
342 approval_description: None,
343 };
344
345 log_verdict(&verdict, "echo hello", Some(log_path), None, &[]);
346
347 let spool = state_home.join("tirith").join("audit-queue.jsonl");
348 assert!(spool.exists(), "remote audit events should be spooled");
349
350 unsafe { std::env::remove_var("XDG_STATE_HOME") };
351 unsafe { std::env::remove_var("TIRITH_API_KEY") };
352 unsafe { std::env::remove_var("TIRITH_SERVER_URL") };
353 }
354
355 #[cfg(unix)]
356 #[test]
357 fn test_audit_refuses_symlink() {
358 let dir = tempfile::tempdir().unwrap();
359 let target = dir.path().join("target");
360 std::fs::write(&target, "original").unwrap();
361
362 let symlink_path = dir.path().join("log.jsonl");
363 std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
364
365 let verdict = Verdict {
366 action: Action::Allow,
367 findings: vec![],
368 tier_reached: 1,
369 timings_ms: crate::verdict::Timings {
370 tier0_ms: 0.0,
371 tier1_ms: 0.0,
372 tier2_ms: None,
373 tier3_ms: None,
374 total_ms: 0.0,
375 },
376 bypass_requested: false,
377 bypass_honored: false,
378 interactive_detected: false,
379 policy_path_used: None,
380 urls_extracted_count: None,
381 requires_approval: None,
382 approval_timeout_secs: None,
383 approval_fallback: None,
384 approval_rule: None,
385 approval_description: None,
386 };
387
388 log_verdict(&verdict, "test cmd", Some(symlink_path), None, &[]);
389
390 assert_eq!(
392 std::fs::read_to_string(&target).unwrap(),
393 "original",
394 "audit should refuse to write through symlink"
395 );
396 }
397}