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
12#[derive(Debug, Clone, Serialize)]
14pub struct AuditEntry {
15 pub timestamp: String,
16 pub session_id: String,
17 pub action: String,
18 pub rule_ids: Vec<String>,
19 pub command_redacted: String,
20 pub bypass_requested: bool,
21 pub bypass_honored: bool,
22 pub interactive: bool,
23 pub policy_path: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub event_id: Option<String>,
26 pub tier_reached: u8,
27}
28
29pub fn log_verdict(
34 verdict: &Verdict,
35 command: &str,
36 log_path: Option<PathBuf>,
37 event_id: Option<String>,
38 custom_dlp_patterns: &[String],
39) {
40 if std::env::var("TIRITH_LOG").ok().as_deref() == Some("0") {
42 return;
43 }
44
45 let path = log_path.or_else(default_log_path);
46 let path = match path {
47 Some(p) => p,
48 None => return,
49 };
50
51 if let Some(parent) = path.parent() {
53 if let Err(e) = fs::create_dir_all(parent) {
54 eprintln!(
55 "tirith: audit: cannot create log dir {}: {e}",
56 parent.display()
57 );
58 return;
59 }
60 }
61
62 let entry = AuditEntry {
63 timestamp: chrono::Utc::now().to_rfc3339(),
64 session_id: crate::session::session_id().to_string(),
65 action: format!("{:?}", verdict.action),
66 rule_ids: verdict
67 .findings
68 .iter()
69 .map(|f| f.rule_id.to_string())
70 .collect(),
71 command_redacted: redact_command(command, custom_dlp_patterns),
72 bypass_requested: verdict.bypass_requested,
73 bypass_honored: verdict.bypass_honored,
74 interactive: verdict.interactive_detected,
75 policy_path: verdict.policy_path_used.clone(),
76 event_id,
77 tier_reached: verdict.tier_reached,
78 };
79
80 let line = match serde_json::to_string(&entry) {
81 Ok(l) => l,
82 Err(e) => {
83 eprintln!("tirith: audit: failed to serialize entry: {e}");
84 return;
85 }
86 };
87
88 #[cfg(unix)]
90 {
91 match std::fs::symlink_metadata(&path) {
92 Ok(meta) if meta.file_type().is_symlink() => {
93 eprintln!(
94 "tirith: audit: refusing to follow symlink at {}",
95 path.display()
96 );
97 return;
98 }
99 _ => {}
100 }
101 }
102
103 let mut open_opts = OpenOptions::new();
105 open_opts.create(true).append(true);
106 #[cfg(unix)]
107 {
108 open_opts.mode(0o600);
109 open_opts.custom_flags(libc::O_NOFOLLOW);
110 }
111 let file = open_opts.open(&path);
112
113 let file = match file {
114 Ok(f) => f,
115 Err(e) => {
116 eprintln!("tirith: audit: cannot open {}: {e}", path.display());
117 return;
118 }
119 };
120
121 #[cfg(unix)]
123 {
124 use std::os::unix::fs::PermissionsExt;
125 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
126 }
127
128 if let Err(e) = file.lock_exclusive() {
129 eprintln!("tirith: audit: cannot lock {}: {e}", path.display());
130 return;
131 }
132
133 let mut writer = std::io::BufWriter::new(&file);
134 if let Err(e) = writeln!(writer, "{line}") {
135 eprintln!("tirith: audit: write failed: {e}");
136 let _ = fs2::FileExt::unlock(&file);
137 return;
138 }
139 if let Err(e) = writer.flush() {
140 eprintln!("tirith: audit: flush failed: {e}");
141 }
142 if let Err(e) = file.sync_all() {
143 eprintln!("tirith: audit: sync failed: {e}");
144 }
145 let _ = fs2::FileExt::unlock(&file);
146
147 let server_url = std::env::var("TIRITH_SERVER_URL")
151 .ok()
152 .filter(|s| !s.is_empty());
153 let api_key = std::env::var("TIRITH_API_KEY")
154 .ok()
155 .filter(|s| !s.is_empty());
156 if let (Some(url), Some(key)) = (server_url, api_key) {
157 if crate::license::current_tier() >= crate::license::Tier::Team {
158 crate::audit_upload::spool_and_upload(&line, &url, &key, None, None);
159 }
160 }
161}
162
163fn default_log_path() -> Option<PathBuf> {
164 crate::policy::data_dir().map(|d| d.join("log.jsonl"))
165}
166
167fn redact_command(cmd: &str, custom_patterns: &[String]) -> String {
168 let dlp_redacted = crate::redact::redact_with_custom(cmd, custom_patterns);
170 let prefix = crate::util::truncate_bytes(&dlp_redacted, 80);
172 if prefix.len() == dlp_redacted.len() {
173 dlp_redacted
174 } else {
175 format!(
176 "{}[...redacted {} bytes]",
177 prefix,
178 dlp_redacted.len() - prefix.len()
179 )
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::verdict::{Action, Verdict};
187
188 #[test]
189 fn test_tirith_log_disabled() {
190 let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
191 let dir = tempfile::tempdir().unwrap();
192 let log_path = dir.path().join("test.jsonl");
193
194 unsafe { std::env::set_var("TIRITH_LOG", "0") };
196
197 let verdict = Verdict {
198 action: Action::Allow,
199 findings: vec![],
200 tier_reached: 1,
201 timings_ms: crate::verdict::Timings {
202 tier0_ms: 0.0,
203 tier1_ms: 0.0,
204 tier2_ms: None,
205 tier3_ms: None,
206 total_ms: 0.0,
207 },
208 bypass_requested: false,
209 bypass_honored: false,
210 interactive_detected: false,
211 policy_path_used: None,
212 urls_extracted_count: None,
213 requires_approval: None,
214 approval_timeout_secs: None,
215 approval_fallback: None,
216 approval_rule: None,
217 approval_description: None,
218 };
219
220 log_verdict(&verdict, "test cmd", Some(log_path.clone()), None, &[]);
221
222 assert!(
224 !log_path.exists(),
225 "log file should not be created when TIRITH_LOG=0"
226 );
227
228 unsafe { std::env::remove_var("TIRITH_LOG") };
230 }
231
232 #[cfg(unix)]
233 #[test]
234 fn test_audit_log_permissions_0600() {
235 use std::os::unix::fs::PermissionsExt;
236
237 let dir = tempfile::tempdir().unwrap();
240 let log_path = dir.path().join("test_perms.jsonl");
241
242 {
243 use std::io::Write;
244 let mut open_opts = OpenOptions::new();
245 open_opts.create(true).append(true);
246 use std::os::unix::fs::OpenOptionsExt;
247 open_opts.mode(0o600);
248 let mut f = open_opts.open(&log_path).unwrap();
249 writeln!(f, "test").unwrap();
250 }
251
252 let meta = std::fs::metadata(&log_path).unwrap();
253 assert_eq!(
254 meta.permissions().mode() & 0o777,
255 0o600,
256 "audit log should be 0600"
257 );
258 }
259
260 #[test]
261 fn test_remote_audit_upload_requires_team_tier() {
262 let _guard = crate::TEST_ENV_LOCK.lock().unwrap();
263
264 let dir = tempfile::tempdir().unwrap();
265 let log_path = dir.path().join("audit.jsonl");
266 let state_home = dir.path().join("state");
267
268 unsafe { std::env::set_var("TIRITH_LICENSE", "!") };
270 unsafe { std::env::set_var("TIRITH_SERVER_URL", "https://example.com") };
271 unsafe { std::env::set_var("TIRITH_API_KEY", "dummy") };
272 unsafe { std::env::set_var("XDG_STATE_HOME", &state_home) };
273 unsafe { std::env::remove_var("TIRITH_LOG") };
274
275 let verdict = Verdict {
276 action: Action::Allow,
277 findings: vec![],
278 tier_reached: 1,
279 timings_ms: crate::verdict::Timings {
280 tier0_ms: 0.0,
281 tier1_ms: 0.0,
282 tier2_ms: None,
283 tier3_ms: None,
284 total_ms: 0.0,
285 },
286 bypass_requested: false,
287 bypass_honored: false,
288 interactive_detected: false,
289 policy_path_used: None,
290 urls_extracted_count: None,
291 requires_approval: None,
292 approval_timeout_secs: None,
293 approval_fallback: None,
294 approval_rule: None,
295 approval_description: None,
296 };
297
298 log_verdict(&verdict, "echo hello", Some(log_path), None, &[]);
299
300 let spool = state_home.join("tirith").join("audit-queue.jsonl");
301 assert!(
302 !spool.exists(),
303 "Community tier must not spool remote audit uploads"
304 );
305
306 unsafe { std::env::remove_var("XDG_STATE_HOME") };
307 unsafe { std::env::remove_var("TIRITH_API_KEY") };
308 unsafe { std::env::remove_var("TIRITH_SERVER_URL") };
309 unsafe { std::env::remove_var("TIRITH_LICENSE") };
310 }
311
312 #[cfg(unix)]
313 #[test]
314 fn test_audit_refuses_symlink() {
315 let dir = tempfile::tempdir().unwrap();
316 let target = dir.path().join("target");
317 std::fs::write(&target, "original").unwrap();
318
319 let symlink_path = dir.path().join("log.jsonl");
320 std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
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, "test cmd", Some(symlink_path), None, &[]);
346
347 assert_eq!(
349 std::fs::read_to_string(&target).unwrap(),
350 "original",
351 "audit should refuse to write through symlink"
352 );
353 }
354}