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 pub entry_type: String,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub event: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub integration: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub hook_type: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub detail: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub elapsed_ms: Option<f64>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub raw_action: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub raw_rule_ids: Option<Vec<String>>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub trust_pattern: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub trust_rule_id: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub trust_action: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub trust_ttl_expires: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub trust_scope: Option<String>,
77}
78
79fn append_to_audit_log(entry: &AuditEntry, log_path: Option<PathBuf>) -> Option<String> {
83 if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
84 return None;
85 }
86
87 let path = log_path.or_else(default_log_path)?;
88
89 if let Some(parent) = path.parent() {
90 if let Err(e) = fs::create_dir_all(parent) {
91 audit_diagnostic(format!(
92 "tirith: audit: cannot create log dir {}: {e}",
93 parent.display()
94 ));
95 return None;
96 }
97 }
98
99 let line = match serde_json::to_string(entry) {
100 Ok(l) => l,
101 Err(e) => {
102 audit_diagnostic(format!("tirith: audit: failed to serialize entry: {e}"));
103 return None;
104 }
105 };
106
107 #[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 None;
118 }
119 _ => {}
120 }
121 }
122
123 let mut open_opts = OpenOptions::new();
124 open_opts.create(true).append(true);
125 #[cfg(unix)]
126 {
127 open_opts.mode(0o600);
128 open_opts.custom_flags(libc::O_NOFOLLOW);
129 }
130 let file = open_opts.open(&path);
131
132 let file = match file {
133 Ok(f) => f,
134 Err(e) => {
135 audit_diagnostic(format!(
136 "tirith: audit: cannot open {}: {e}",
137 path.display()
138 ));
139 return None;
140 }
141 };
142
143 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
148 }
149
150 if let Err(e) = file.lock_exclusive() {
151 audit_diagnostic(format!(
152 "tirith: audit: cannot lock {}: {e}",
153 path.display()
154 ));
155 return None;
156 }
157
158 let mut writer = std::io::BufWriter::new(&file);
159 if let Err(e) = writeln!(writer, "{line}") {
160 audit_diagnostic(format!("tirith: audit: write failed: {e}"));
161 let _ = fs2::FileExt::unlock(&file);
162 return None;
163 }
164 if let Err(e) = writer.flush() {
165 audit_diagnostic(format!("tirith: audit: flush failed: {e}"));
166 }
167 if let Err(e) = file.sync_all() {
168 audit_diagnostic(format!("tirith: audit: sync failed: {e}"));
169 }
170 let _ = fs2::FileExt::unlock(&file);
171
172 Some(line)
173}
174
175pub fn log_verdict(
180 verdict: &Verdict,
181 command: &str,
182 log_path: Option<PathBuf>,
183 event_id: Option<String>,
184 custom_dlp_patterns: &[String],
185) {
186 log_verdict_with_raw(
187 verdict,
188 command,
189 log_path,
190 event_id,
191 custom_dlp_patterns,
192 None,
193 None,
194 );
195}
196
197pub fn log_verdict_with_raw(
202 verdict: &Verdict,
203 command: &str,
204 log_path: Option<PathBuf>,
205 event_id: Option<String>,
206 custom_dlp_patterns: &[String],
207 raw_action: Option<String>,
208 raw_rule_ids: Option<Vec<String>>,
209) {
210 let entry = AuditEntry {
211 timestamp: chrono::Utc::now().to_rfc3339(),
212 session_id: crate::session::resolve_session_id(),
213 action: format!("{:?}", verdict.action),
214 rule_ids: verdict
215 .findings
216 .iter()
217 .map(|f| f.rule_id.to_string())
218 .collect(),
219 command_redacted: redact_command(command, custom_dlp_patterns),
220 bypass_requested: verdict.bypass_requested,
221 bypass_honored: verdict.bypass_honored,
222 interactive: verdict.interactive_detected,
223 policy_path: verdict.policy_path_used.clone(),
224 event_id,
225 tier_reached: verdict.tier_reached,
226 entry_type: "verdict".to_string(),
227 event: None,
228 integration: None,
229 hook_type: None,
230 detail: None,
231 elapsed_ms: None,
232 raw_action,
233 raw_rule_ids,
234 trust_pattern: None,
235 trust_rule_id: None,
236 trust_action: None,
237 trust_ttl_expires: None,
238 trust_scope: None,
239 };
240
241 let line = match append_to_audit_log(&entry, log_path) {
242 Some(l) => l,
243 None => return,
244 };
245
246 let server_url = std::env::var("TIRITH_SERVER_URL")
249 .ok()
250 .filter(|s| !s.is_empty());
251 let api_key = std::env::var("TIRITH_API_KEY")
252 .ok()
253 .filter(|s| !s.is_empty());
254 if let (Some(url), Some(key)) = (server_url, api_key) {
255 crate::audit_upload::spool_and_upload(&line, &url, &key, None, None);
256 }
257}
258
259pub fn log_hook_event(
264 integration: &str,
265 hook_type: &str,
266 event: &str,
267 elapsed_ms: Option<f64>,
268 detail: Option<&str>,
269) {
270 let entry = AuditEntry {
271 timestamp: chrono::Utc::now().to_rfc3339(),
272 session_id: crate::session::resolve_session_id(),
273 action: "hook".to_string(),
274 rule_ids: vec![],
275 command_redacted: String::new(),
276 bypass_requested: false,
277 bypass_honored: false,
278 interactive: false,
279 policy_path: None,
280 event_id: None,
281 tier_reached: 0,
282 entry_type: "hook_telemetry".to_string(),
283 event: Some(event.to_string()),
284 integration: Some(integration.to_string()),
285 hook_type: Some(hook_type.to_string()),
286 detail: detail.map(String::from),
287 elapsed_ms,
288 raw_action: None,
289 raw_rule_ids: None,
290 trust_pattern: None,
291 trust_rule_id: None,
292 trust_action: None,
293 trust_ttl_expires: None,
294 trust_scope: None,
295 };
296
297 append_to_audit_log(&entry, None);
298}
299
300pub fn log_trust_change(
305 pattern: &str,
306 rule_id: Option<&str>,
307 trust_action: &str,
308 ttl_expires: Option<&str>,
309 scope: &str,
310) {
311 let entry = AuditEntry {
312 timestamp: chrono::Utc::now().to_rfc3339(),
313 session_id: crate::session::resolve_session_id(),
314 action: "trust".to_string(),
315 rule_ids: vec![],
316 command_redacted: String::new(),
317 bypass_requested: false,
318 bypass_honored: false,
319 interactive: false,
320 policy_path: None,
321 event_id: None,
322 tier_reached: 0,
323 entry_type: "trust_change".to_string(),
324 event: None,
325 integration: None,
326 hook_type: None,
327 detail: None,
328 elapsed_ms: None,
329 raw_action: None,
330 raw_rule_ids: None,
331 trust_pattern: Some(pattern.to_string()),
332 trust_rule_id: rule_id.map(String::from),
333 trust_action: Some(trust_action.to_string()),
334 trust_ttl_expires: ttl_expires.map(String::from),
335 trust_scope: Some(scope.to_string()),
336 };
337
338 append_to_audit_log(&entry, None);
339}
340
341fn default_log_path() -> Option<PathBuf> {
342 crate::policy::data_dir().map(|d| d.join("log.jsonl"))
343}
344
345fn redact_command(cmd: &str, custom_patterns: &[String]) -> String {
346 let dlp_redacted = crate::redact::redact_with_custom(cmd, custom_patterns);
347 let prefix = crate::util::truncate_bytes(&dlp_redacted, 80);
348 if prefix.len() == dlp_redacted.len() {
349 dlp_redacted
350 } else {
351 format!(
352 "{}[...redacted {} bytes]",
353 prefix,
354 dlp_redacted.len() - prefix.len()
355 )
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::verdict::{Action, Verdict};
363
364 #[test]
365 fn test_tirith_log_disabled() {
366 let _guard = crate::TEST_ENV_LOCK
367 .lock()
368 .unwrap_or_else(|e| e.into_inner());
369 let dir = tempfile::tempdir().unwrap();
370 let log_path = dir.path().join("test.jsonl");
371
372 unsafe { std::env::set_var("TIRITH_LOG", "0") };
373
374 let verdict = Verdict {
375 action: Action::Allow,
376 findings: vec![],
377 tier_reached: 1,
378 timings_ms: crate::verdict::Timings {
379 tier0_ms: 0.0,
380 tier1_ms: 0.0,
381 tier2_ms: None,
382 tier3_ms: None,
383 total_ms: 0.0,
384 },
385 bypass_requested: false,
386 bypass_honored: false,
387 bypass_available: false,
388 interactive_detected: false,
389 policy_path_used: None,
390 urls_extracted_count: None,
391 requires_approval: None,
392 approval_timeout_secs: None,
393 approval_fallback: None,
394 approval_rule: None,
395 approval_description: None,
396 escalation_reason: None,
397 };
398
399 log_verdict(&verdict, "test cmd", Some(log_path.clone()), None, &[]);
400
401 assert!(
402 !log_path.exists(),
403 "log file should not be created when TIRITH_LOG=0"
404 );
405
406 unsafe { std::env::remove_var("TIRITH_LOG") };
407 }
408
409 #[test]
410 fn test_audit_diagnostics_disabled_by_default() {
411 let _guard = crate::TEST_ENV_LOCK
412 .lock()
413 .unwrap_or_else(|e| e.into_inner());
414 unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
415 assert!(!audit_diagnostics_enabled());
416 }
417
418 #[test]
419 fn test_audit_diagnostics_enabled_by_env() {
420 let _guard = crate::TEST_ENV_LOCK
421 .lock()
422 .unwrap_or_else(|e| e.into_inner());
423 unsafe { std::env::set_var("TIRITH_AUDIT_DEBUG", "true") };
424 assert!(audit_diagnostics_enabled());
425 unsafe { std::env::remove_var("TIRITH_AUDIT_DEBUG") };
426 }
427
428 #[cfg(unix)]
429 #[test]
430 fn test_audit_log_permissions_0600() {
431 use std::os::unix::fs::PermissionsExt;
432
433 let dir = tempfile::tempdir().unwrap();
436 let log_path = dir.path().join("test_perms.jsonl");
437
438 {
439 use std::io::Write;
440 let mut open_opts = OpenOptions::new();
441 open_opts.create(true).append(true);
442 use std::os::unix::fs::OpenOptionsExt;
443 open_opts.mode(0o600);
444 let mut f = open_opts.open(&log_path).unwrap();
445 writeln!(f, "test").unwrap();
446 }
447
448 let meta = std::fs::metadata(&log_path).unwrap();
449 assert_eq!(
450 meta.permissions().mode() & 0o777,
451 0o600,
452 "audit log should be 0600"
453 );
454 }
455
456 #[cfg(unix)]
457 #[test]
458 fn test_remote_audit_upload_spools_when_configured() {
459 let _guard = crate::TEST_ENV_LOCK
460 .lock()
461 .unwrap_or_else(|e| e.into_inner());
462
463 let dir = tempfile::tempdir().unwrap();
464 let log_path = dir.path().join("audit.jsonl");
465 let state_home = dir.path().join("state");
466
467 unsafe { std::env::set_var("TIRITH_SERVER_URL", "http://127.0.0.1") };
469 unsafe { std::env::set_var("TIRITH_API_KEY", "dummy") };
470 unsafe { std::env::set_var("XDG_STATE_HOME", &state_home) };
471 unsafe { std::env::remove_var("TIRITH_LOG") };
472
473 let verdict = Verdict {
474 action: Action::Allow,
475 findings: vec![],
476 tier_reached: 1,
477 timings_ms: crate::verdict::Timings {
478 tier0_ms: 0.0,
479 tier1_ms: 0.0,
480 tier2_ms: None,
481 tier3_ms: None,
482 total_ms: 0.0,
483 },
484 bypass_requested: false,
485 bypass_honored: false,
486 bypass_available: false,
487 interactive_detected: false,
488 policy_path_used: None,
489 urls_extracted_count: None,
490 requires_approval: None,
491 approval_timeout_secs: None,
492 approval_fallback: None,
493 approval_rule: None,
494 approval_description: None,
495 escalation_reason: None,
496 };
497
498 log_verdict(&verdict, "echo hello", Some(log_path), None, &[]);
499
500 let spool = state_home.join("tirith").join("audit-queue.jsonl");
501 assert!(spool.exists(), "remote audit events should be spooled");
502
503 unsafe { std::env::remove_var("XDG_STATE_HOME") };
504 unsafe { std::env::remove_var("TIRITH_API_KEY") };
505 unsafe { std::env::remove_var("TIRITH_SERVER_URL") };
506 }
507
508 #[cfg(unix)]
509 #[test]
510 fn test_audit_refuses_symlink() {
511 let dir = tempfile::tempdir().unwrap();
512 let target = dir.path().join("target");
513 std::fs::write(&target, "original").unwrap();
514
515 let symlink_path = dir.path().join("log.jsonl");
516 std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
517
518 let verdict = Verdict {
519 action: Action::Allow,
520 findings: vec![],
521 tier_reached: 1,
522 timings_ms: crate::verdict::Timings {
523 tier0_ms: 0.0,
524 tier1_ms: 0.0,
525 tier2_ms: None,
526 tier3_ms: None,
527 total_ms: 0.0,
528 },
529 bypass_requested: false,
530 bypass_honored: false,
531 bypass_available: false,
532 interactive_detected: false,
533 policy_path_used: None,
534 urls_extracted_count: None,
535 requires_approval: None,
536 approval_timeout_secs: None,
537 approval_fallback: None,
538 approval_rule: None,
539 approval_description: None,
540 escalation_reason: None,
541 };
542
543 log_verdict(&verdict, "test cmd", Some(symlink_path), None, &[]);
544
545 assert_eq!(
546 std::fs::read_to_string(&target).unwrap(),
547 "original",
548 "audit should refuse to write through symlink"
549 );
550 }
551}