1use std::fs::{self, OpenOptions};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::Serialize;
16
17use crate::error::ComputerError;
18
19static AUDIT_LOCK: Mutex<()> = Mutex::new(());
20
21std::thread_local! {
22 static STATE_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
23}
24
25pub struct StateDirGuard {
27 previous: Option<PathBuf>,
28}
29
30impl Drop for StateDirGuard {
31 fn drop(&mut self) {
32 let previous = self.previous.take();
33 STATE_DIR.with(|slot| *slot.borrow_mut() = previous);
34 }
35}
36
37pub fn push_state_dir(dir: impl Into<PathBuf>) -> StateDirGuard {
41 let previous = STATE_DIR.with(|slot| slot.borrow_mut().replace(dir.into()));
42 StateDirGuard { previous }
43}
44
45pub fn state_dir() -> Option<PathBuf> {
49 if let Some(overridden) = STATE_DIR.with(|slot| slot.borrow().clone()) {
50 return Some(overridden);
51 }
52 ambient_state_dir()
53}
54
55#[cfg(test)]
57fn ambient_state_dir() -> Option<PathBuf> {
58 None
59}
60
61#[cfg(not(test))]
62fn ambient_state_dir() -> Option<PathBuf> {
63 if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
64 if !xdg.is_empty() {
65 return Some(PathBuf::from(xdg).join("computer-use"));
66 }
67 }
68 production_state_dir()
69}
70
71#[cfg(not(test))]
72fn production_state_dir() -> Option<PathBuf> {
73 #[cfg(windows)]
74 {
75 if let Some(local) = std::env::var_os("LOCALAPPDATA") {
76 if !local.is_empty() {
77 return Some(PathBuf::from(local).join("computer-use"));
78 }
79 }
80 }
81 dirs::home_dir().map(|home| home.join(".local").join("state").join("computer-use"))
82}
83
84#[derive(Debug, Clone)]
86pub struct AuditRecord<'a> {
87 pub session: &'a str,
89 pub action: &'a str,
91 pub bundle_id: Option<&'a str>,
93 pub pid: Option<i32>,
95 pub window_id: Option<u32>,
97 pub element: Option<u32>,
99 pub code: &'a str,
101 pub decision: &'a str,
103 pub source: &'a str,
105 pub tier: Option<&'a str>,
107 pub chars: Option<u64>,
109}
110
111#[derive(Serialize)]
112struct AuditLine<'a> {
113 ts: String,
114 session: &'a str,
115 action: &'a str,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 bundle_id: Option<&'a str>,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pid: Option<i32>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 window_id: Option<u32>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 element: Option<u32>,
124 code: &'a str,
125 decision: &'a str,
126 source: &'a str,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 tier: Option<&'a str>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 chars: Option<u64>,
131}
132
133pub fn record(entry: &AuditRecord<'_>) -> Result<(), ComputerError> {
135 let Some(dir) = state_dir() else {
136 return Ok(());
137 };
138 let line = AuditLine {
139 ts: utc_timestamp(unix_secs()),
140 session: entry.session,
141 action: entry.action,
142 bundle_id: entry.bundle_id,
143 pid: entry.pid,
144 window_id: entry.window_id,
145 element: entry.element,
146 code: entry.code,
147 decision: entry.decision,
148 source: entry.source,
149 tier: entry.tier,
150 chars: entry.chars,
151 };
152 let text = serde_json::to_string(&line)
153 .map_err(|error| ComputerError::ExecutionFailed(format!("audit encode failed: {error}")))?;
154 let _guard = AUDIT_LOCK
155 .lock()
156 .unwrap_or_else(|poison| poison.into_inner());
157 append_line(&dir.join("audit.jsonl"), &text)
158}
159
160fn append_line(path: &Path, line: &str) -> Result<(), ComputerError> {
161 if let Some(parent) = path.parent() {
162 fs::create_dir_all(parent).map_err(ComputerError::Io)?;
163 #[cfg(unix)]
164 {
165 use std::os::unix::fs::PermissionsExt;
166 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
167 }
168 }
169 let mut options = OpenOptions::new();
170 options.create(true).append(true);
171 #[cfg(unix)]
172 {
173 use std::os::unix::fs::OpenOptionsExt;
174 options.mode(0o600);
175 #[cfg(target_os = "linux")]
177 options.custom_flags(0x20000);
178 #[cfg(target_os = "macos")]
179 options.custom_flags(0x0100);
180 }
181 let mut file = options.open(path).map_err(ComputerError::Io)?;
182 file.write_all(line.as_bytes()).map_err(ComputerError::Io)?;
183 file.write_all(b"\n").map_err(ComputerError::Io)?;
184 Ok(())
185}
186
187fn unix_secs() -> i64 {
188 SystemTime::now()
189 .duration_since(UNIX_EPOCH)
190 .map(|duration| duration.as_secs() as i64)
191 .unwrap_or(0)
192}
193
194pub fn utc_timestamp(secs: i64) -> String {
196 let days = secs.div_euclid(86_400);
197 let tod = secs.rem_euclid(86_400) as u32;
198 let (year, month, day) = civil_from_days(days);
199 let hour = tod / 3600;
200 let minute = (tod % 3600) / 60;
201 let second = tod % 60;
202 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
203}
204
205fn civil_from_days(days: i64) -> (i32, u32, u32) {
207 let z = days + 719_468;
208 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
209 let doe = (z - era * 146_097) as u64;
210 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
211 let y = yoe as i64 + era * 400;
212 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
213 let mp = (5 * doy + 2) / 153;
214 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
215 let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
216 let year = (if month <= 2 { y + 1 } else { y }) as i32;
217 (year, month, day)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn unix_epoch_formats_as_1970() {
226 assert_eq!(utc_timestamp(0), "1970-01-01T00:00:00Z");
227 assert_eq!(utc_timestamp(86_400), "1970-01-02T00:00:00Z");
228 }
229
230 #[test]
231 fn writer_redacts_a_value_because_it_has_no_value_field() {
232 let dir = std::env::temp_dir().join(format!(
233 "computer-use-audit-{}-{}",
234 std::process::id(),
235 std::time::SystemTime::now()
236 .duration_since(UNIX_EPOCH)
237 .map(|duration| duration.as_nanos())
238 .unwrap_or(0)
239 ));
240 let _ = fs::remove_dir_all(&dir);
241 fs::create_dir_all(&dir).unwrap();
242 let _guard = push_state_dir(&dir);
243 record(&AuditRecord {
244 session: "9",
245 action: "set_value",
246 bundle_id: Some("com.apple.Notes"),
247 pid: Some(412),
248 window_id: Some(88),
249 element: Some(12),
250 code: "policy_denied",
251 decision: "deny",
252 source: "builtin",
253 tier: Some("ax"),
254 chars: None,
255 })
256 .unwrap();
257 let text = fs::read_to_string(dir.join("audit.jsonl")).unwrap();
258 assert!(text.contains("\"action\":\"set_value\""));
259 assert!(text.contains("\"code\":\"policy_denied\""));
260 assert!(text.contains("\"decision\":\"deny\""));
261 assert!(text.contains("\"source\":\"builtin\""));
262 assert!(!text.contains("\"value\""));
263 assert!(!text.contains("super-secret-token"));
264 let _ = fs::remove_dir_all(&dir);
265 }
266}