Skip to main content

computer_use/
audit.rs

1//! Append-only JSONL audit log.
2//!
3//! The file is `${XDG_STATE_HOME:-~/.local/state}/computer-use/audit.jsonl`,
4//! or `%LOCALAPPDATA%\computer-use\audit.jsonl` on Windows when `XDG_STATE_HOME`
5//! is unset. Typed text, element values, clipboard bytes, and blocked URLs are
6//! not fields on the line, so they are never written. A write failure is
7//! returned to the caller and does not by itself fail the desktop action.
8
9use 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
25/// Restores the previous test state directory when dropped.
26pub 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
37/// Point audit and pause files at `dir` until the guard drops.
38///
39/// Unit tests use this so they never create `~/.local/state`.
40pub 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
45/// Directory that holds `audit.jsonl` and the `paused` marker.
46///
47/// `None` in tests that did not set a state directory or `XDG_STATE_HOME`.
48pub 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/// Unit tests ignore `XDG_STATE_HOME` so they never append to the real state directory.
56#[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/// One audit record. Fields that would carry typed text are intentionally absent.
85#[derive(Debug, Clone)]
86pub struct AuditRecord<'a> {
87    /// Session id, or the process id when the caller did not set one.
88    pub session: &'a str,
89    /// Action name such as `press` or `quit`. Never the typed text.
90    pub action: &'a str,
91    /// Bundle id when the target resolved.
92    pub bundle_id: Option<&'a str>,
93    /// Process id when the target resolved.
94    pub pid: Option<i32>,
95    /// Window id when one was targeted.
96    pub window_id: Option<u32>,
97    /// Element index when the action named one.
98    pub element: Option<u32>,
99    /// `ok` or the error code. This is the action outcome, not a constant.
100    pub code: &'a str,
101    /// `allow`, `deny`, or `confirm`.
102    pub decision: &'a str,
103    /// `builtin`, `admin`, `user`, `default`, or another policy source.
104    pub source: &'a str,
105    /// Input tier when the action reported one.
106    pub tier: Option<&'a str>,
107    /// Character count for `clipboard_set`. The text itself is never stored.
108    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
133/// Append one JSON line. Does nothing when no state directory is available in tests.
134pub 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        // O_NOFOLLOW: do not append through a symlink planted on the log path.
176        #[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
194/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SSZ`.
195pub 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
205/// Howard Hinnant's `civil_from_days`, shifted so day 0 is 1970-01-01.
206fn 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}