From 7e07e04badd3a7568309d2a2b5739b11c88284fe Mon Sep 17 00:00:00 2001 From: mrigbozurike Date: Mon, 5 Oct 2026 09:30:25 +0100 Subject: [PATCH] audit: record focused window title and screenshot hash on every action Signed-off-by: mrigbozurike --- CHANGELOG.md | 11 ++ Cargo.lock | 75 +++++++++++- Cargo.toml | 13 ++- SECURITY.md | 7 +- docs/audit.md | 2 + docs/configuration.md | 36 +++++- src/config.rs | 11 ++ src/desktop/local/capture.rs | 76 +++++++++--- src/desktop/local/mod.rs | 30 +++++ src/desktop/local/win_hypr.rs | 24 ++++ src/desktop/local/win_mac.rs | 30 ++++- src/desktop/local/win_windows.rs | 69 ++++++++++- src/desktop/mod.rs | 8 ++ src/doctor.rs | 14 +++ src/main.rs | 10 +- src/server/audit.rs | 119 ++++++++++++++++++- src/server/routes.rs | 80 +++++++++---- src/server/tests.rs | 194 +++++++++++++++++++++++++++++-- 18 files changed, 743 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1891556..54144a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ builds the GitHub release body from the matching `## ` section. ## Unreleased +### Added +- **Audit lines record the focused window and the screenshot hash.** Screenshot, input, focus + and clipboard entries carry `window` (`app: title` of the window that had focus when the + request arrived, sanitised and truncated like every other field), and screenshot entries carry + `screenshot_sha256` of the bytes returned, so an action can be tied to what was on screen and + a saved image to the line that produced it. The lookup is a direct focused-window query per + platform — `GetForegroundWindow` on Windows, `hyprctl activewindow` on Hyprland, + `NSWorkspace`'s `activeApplication` on macOS — and reads the title and application of that one + window, rather than enumerating the desktop. It runs off the request thread under one timeout + for every backend; `rdc doctor` reports how long it takes. `[serve.audit].window_titles = false` omits it. + ## 0.4.0 — 2026-09-11 ### Added diff --git a/Cargo.lock b/Cargo.lock index 7224506..fc4541e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -456,7 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core", ] @@ -595,6 +604,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -619,6 +637,16 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -653,6 +681,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1078,6 +1116,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2250,11 +2298,13 @@ dependencies = [ "image", "libc", "objc2-app-kit", + "objc2-foundation", "reqwest", "rmcp", "schemars", "serde", "serde_json", + "sha2", "tailscale-localapi", "thiserror", "tokio", @@ -2622,6 +2672,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3069,6 +3130,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -3140,6 +3207,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 8fc6be9..e4d69e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ rmcp = { version = "3", features = ["server", "macros", "transport-io", "schemar schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } toml = "0.9" @@ -40,7 +41,17 @@ tailscale-localapi = "0.6" [target.'cfg(target_os = "macos")'.dependencies] enigo = { version = "0.6", default-features = false } tailscale-localapi = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSRunningApplication"] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSRunningApplication", + "NSWorkspace", +] } +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSString", + "NSDictionary", + "NSValue", +] } [target.'cfg(target_os = "windows")'.dependencies] enigo = { version = "0.6", default-features = false } diff --git a/SECURITY.md b/SECURITY.md index 6c5453b..6a06b2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,8 +41,11 @@ TLS: the tailnet's WireGuard layer provides encryption and the identity. signed bundle you built or verified; see `scripts/macos`. **Audit.** Every request and rejection is appended to a JSON-lines audit log (mode 0600, size -rotated) with the caller's identity, an action summary, the outcome and timing. Typed text is -never logged, only its length. Read it with `rdc audit`. +rotated) with a timestamp, the caller's identity, an action summary, the focused window's +`app: title` at the moment of the request, the outcome and timing, and for screenshots the +SHA-256 of the returned image. Typed text is never logged, only its length. Window titles can be +switched off (`[serve.audit].window_titles = false`) where they would leak more than they +explain. Read it with `rdc audit`. **Not yet implemented** (tracked as issues): rate limiting, and a pause when a human is physically using the input devices. diff --git a/docs/audit.md b/docs/audit.md index 0139495..9f26d4b 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -35,6 +35,8 @@ One entry: | `status` | HTTP status returned | | `detail` | why it was denied or failed | | `ms` | how long the request took | +| `window` | the focused window when the request arrived, as `app: title`, truncated to 160 characters. Omitted when `[serve.audit].window_titles = false`, when nothing was focused, or when the lookup did not answer in time. | +| `screenshot_sha256` | SHA-256 of the image bytes a screenshot request returned, so a saved screenshot can be matched to the line that produced it | | `via` | added by the viewer: the node that sent this entry over the network | Strings from the network (window titles, key chords, host names) have control characters diff --git a/docs/configuration.md b/docs/configuration.md index 1194623..6f58e3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -217,13 +217,43 @@ Every authorized request and every rejection is appended as one JSON object per {"ts":"2026-09-09T16:08:55.979Z","peer":"100.64.0.7","login":"alice@example.com","node":"laptop", "method":"POST","path":"/v1/act","action":"input.click 100,100 Left x1","outcome":"denied", "status":403,"detail":"alice@example.com may not use `input` on this machine","ms":0} +{"ts":"2026-09-09T16:09:02.114Z","peer":"100.64.0.7","login":"alice@example.com","node":"laptop", + "method":"GET","path":"/v1/screenshot","action":"screenshot all png max=Some(1568)","outcome":"ok", + "status":200,"ms":312,"window":"Firefox: Inbox — Mail","screenshot_sha256":"9f86d081…"} ``` `outcome` is `ok`, `denied` (host, identity or capability) or `error`. `action` describes the request without its payload: typed text is recorded only as a character count. Key chords, window -selectors and error messages are recorded as sent, with control characters replaced, so a hostile -value cannot break the file or the terminal you read it in. The file and its rotated copies are -mode 0600. Read it with `rdc audit` (`-n`, `--json`, `--path`). +selectors, window titles and error messages are recorded as sent, with control characters +replaced, so a hostile value cannot break the file or the terminal you read it in. Two fields tie +each line to what was actually on screen: + +- `window` is the focused window when the request arrived (`app: title`, truncated to 160 + characters), recorded for screenshot, input, focus and clipboard requests. It answers "what + was that click aimed at?" after the fact. Titles are sanitised and truncated like every other + field, so a hostile window name cannot forge a line or rewrite your terminal. They can also + carry document names, URLs and mail subjects; set `window_titles = false` to leave them out. + + The lookup asks the platform for the focused window as directly as it can — `GetForegroundWindow` + on Windows, `hyprctl -j activewindow` on Hyprland, `NSWorkspace`'s `activeApplication` plus that + application's frontmost window on macOS — and reads a title and application name for that one + window only. It runs off the request thread under a 250 ms timeout; if it does not answer, the + line is written without a window. `rdc doctor` prints how long it takes on your machine. +- `screenshot_sha256` is the SHA-256 of the image bytes a screenshot request returned, so a + screenshot an agent saved (or that appears in a transcript) can be matched to the exact audit + line that produced it. Every MCP action ends with a fresh screenshot, so every tool call an + agent makes leaves at least one hashed line. + +The file and its rotated copies are mode 0600. Read it with `rdc audit` (`-n`, `--json`, +`--path`); the table view shows the window in brackets and the first 12 hex digits of the hash. + +| Key | Default | Meaning | +|---|---|---| +| `enabled` | `true` | write the log at all | +| `path` | platform state dir | where to write it | +| `max_size_mb` | `50` | rotate above this size | +| `keep` | `5` | rotated files to keep | +| `window_titles` | `true` | record the focused window on each action | Default location: `~/.local/state/rdc/audit.jsonl` (Linux), `~/Library/Application Support/rdc/audit.jsonl` (macOS), `%LOCALAPPDATA%\rdc\audit.jsonl` (Windows). diff --git a/src/config.rs b/src/config.rs index e9d7dd1..9479438 100644 --- a/src/config.rs +++ b/src/config.rs @@ -215,6 +215,11 @@ pub struct AuditConfig { /// which identifies the sender through Tailscale. #[serde(default)] pub stream_token: Option, + /// Record the focused window (`app: title`) on every screenshot, input, focus and + /// clipboard request. Default on. Titles can reveal document names, URLs or mail subjects; + /// turn this off if the log is read by people who should not see them. + #[serde(default = "yes")] + pub window_titles: bool, } impl Default for AuditConfig { @@ -226,6 +231,7 @@ impl Default for AuditConfig { keep: default_audit_keep(), stream: None, stream_token: None, + window_titles: true, } } } @@ -293,6 +299,8 @@ impl AuditViewConfig { keep: self.keep, stream: None, stream_token: None, + // The viewer stores what daemons send; it never looks up windows itself. + window_titles: false, } } } @@ -554,6 +562,9 @@ allow = [{ who = "x", can = "shell" }]"#, let cfg: Config = toml::from_str("[serve]\nallow = ['a']").unwrap(); assert!(cfg.serve.audit.enabled); assert_eq!(cfg.serve.audit.max_size_mb, 50); + assert!(cfg.serve.audit.window_titles); + let cfg: Config = toml::from_str("[serve.audit]\nwindow_titles = false").unwrap(); + assert!(!cfg.serve.audit.window_titles); let cfg: Config = toml::from_str("[serve.audit]\nenabled = false\npath = '/tmp/x.jsonl'").unwrap(); assert!(!cfg.serve.audit.enabled); assert_eq!(cfg.serve.audit.resolved_path(), PathBuf::from("/tmp/x.jsonl")); diff --git a/src/desktop/local/capture.rs b/src/desktop/local/capture.rs index 46c03e5..941b174 100644 --- a/src/desktop/local/capture.rs +++ b/src/desktop/local/capture.rs @@ -43,33 +43,73 @@ pub fn displays() -> Result> { Ok(out) } +/// Read one xcap window into our shape. Every accessor here is a separate query into the +/// platform's window server — on macOS each one re-runs `CGWindowListCopyWindowInfo` over the +/// whole desktop — so call this for as few windows as the caller actually needs. +/// `focused` is passed in because the caller often already knows it more cheaply than +/// `is_focused()` can work it out. +fn to_window(w: &xcap::Window, focused: bool) -> Result> { + let title = w.title().unwrap_or_default(); + let app = w.app_name().unwrap_or_default(); + if title.is_empty() && app.is_empty() { + return Ok(None); + } + Ok(Some(Window { + id: w.id().map_err(xe)? as u64, + pid: w.pid().unwrap_or(0), + app, + title, + rect: Rect { + x: w.x().unwrap_or(0), + y: w.y().unwrap_or(0), + w: w.width().unwrap_or(0), + h: w.height().unwrap_or(0), + }, + focused, + minimized: w.is_minimized().unwrap_or(false), + })) +} + pub fn windows() -> Result> { let ws = xcap::Window::all().map_err(xe)?; let mut out = Vec::new(); for w in ws { - let title = w.title().unwrap_or_default(); - let app = w.app_name().unwrap_or_default(); - if title.is_empty() && app.is_empty() { - continue; + let focused = w.is_focused().unwrap_or(false); + if let Some(win) = to_window(&w, focused)? { + out.push(win); } - out.push(Window { - id: w.id().map_err(xe)? as u64, - pid: w.pid().unwrap_or(0), - app, - title, - rect: Rect { - x: w.x().unwrap_or(0), - y: w.y().unwrap_or(0), - w: w.width().unwrap_or(0), - h: w.height().unwrap_or(0), - }, - focused: w.is_focused().unwrap_or(false), - minimized: w.is_minimized().unwrap_or(false), - }); } Ok(out) } +/// The focused window, reading the accessors for that one window only. +/// +/// Used where the platform has no cheaper single-window query of its own. xcap lists windows +/// front to back, so this normally stops at the first entry. +pub fn focused_window() -> Result> { + for w in xcap::Window::all().map_err(xe)? { + if w.is_focused().unwrap_or(false) { + return to_window(&w, true); + } + } + Ok(None) +} + +/// The frontmost window belonging to `pid`, reading the accessors for that window only. +/// +/// On macOS every accessor is a full `CGWindowListCopyWindowInfo` scan, so the caller finds the +/// pid once (from `NSWorkspace`) and this walks one window list to the first match — front to +/// back, so normally the first entry — instead of reading every window's title and owner. +#[cfg(target_os = "macos")] +pub fn window_of_pid(pid: u32) -> Result> { + for w in xcap::Window::all().map_err(xe)? { + if w.pid().unwrap_or(0) == pid { + return to_window(&w, true); + } + } + Ok(None) +} + /// Find the xcap monitor that corresponds to one of our logical displays. fn monitor_for(d: &Display) -> Result { let mons = Monitor::all().map_err(xe)?; diff --git a/src/desktop/local/mod.rs b/src/desktop/local/mod.rs index 83c6bfe..00b59c2 100644 --- a/src/desktop/local/mod.rs +++ b/src/desktop/local/mod.rs @@ -84,6 +84,36 @@ impl Desktop for LocalDesktop { .map_err(|e| RdcError::Backend(format!("window task failed: {e}")))? } + async fn focused_window(&self) -> Result> { + // Every backend here talks to a window server or a subprocess, so none of it belongs on + // an executor thread. How long it may take is bounded by the caller; see + // `server::routes::FOCUS_LOOKUP_TIMEOUT`. + #[cfg(target_os = "linux")] + if let Some(h) = &self.hypr { + let h = *h; + return tokio::task::spawn_blocking(move || h.focused_window()) + .await + .map_err(|e| RdcError::Backend(format!("focused-window task failed: {e}")))?; + } + #[cfg(target_os = "macos")] + { + return tokio::task::spawn_blocking(win_mac::focused_window) + .await + .map_err(|e| RdcError::Backend(format!("focused-window task failed: {e}")))?; + } + #[cfg(target_os = "windows")] + { + return tokio::task::spawn_blocking(win_windows::focused_window) + .await + .map_err(|e| RdcError::Backend(format!("focused-window task failed: {e}")))?; + } + // X11 and other Linux compositors have no single-window query of their own. + #[allow(unreachable_code)] + tokio::task::spawn_blocking(capture::focused_window) + .await + .map_err(|e| RdcError::Backend(format!("focused-window task failed: {e}")))? + } + async fn focus(&self, target: WindowTarget) -> Result<()> { let windows = self.windows().await?; let w = find_window(&windows, &target) diff --git a/src/desktop/local/win_hypr.rs b/src/desktop/local/win_hypr.rs index 1371b2f..ed93933 100644 --- a/src/desktop/local/win_hypr.rs +++ b/src/desktop/local/win_hypr.rs @@ -5,6 +5,7 @@ use crate::proto::*; use serde::Deserialize; use std::process::Command; +#[derive(Clone, Copy)] pub struct Hyprland; #[derive(Deserialize)] @@ -95,6 +96,29 @@ impl Hyprland { Ok(out) } + /// `hyprctl activewindow` returns just the focused client (or an empty object when nothing + /// is focused), so this avoids serialising the whole client list per action. + pub fn focused_window(&self) -> Result> { + let raw = self.run(&["-j", "activewindow"])?; + if raw.trim().is_empty() || raw.trim() == "{}" { + return Ok(None); + } + let c: HClient = + serde_json::from_str(&raw).map_err(|e| RdcError::Backend(format!("hyprctl activewindow json: {e}")))?; + if !c.mapped { + return Ok(None); + } + Ok(Some(Window { + id: u64::from_str_radix(c.address.trim_start_matches("0x"), 16).unwrap_or(0), + pid: c.pid.max(0) as u32, + app: c.class, + title: c.title, + rect: Rect { x: c.at.0, y: c.at.1, w: c.size.0.max(0) as u32, h: c.size.1.max(0) as u32 }, + focused: true, + minimized: c.hidden, + })) + } + pub fn focus(&self, w: &Window) -> Result<()> { self.run(&["dispatch", "focuswindow", &format!("address:0x{:x}", w.id)]).map(|_| ()) } diff --git a/src/desktop/local/win_mac.rs b/src/desktop/local/win_mac.rs index df430ec..9500442 100644 --- a/src/desktop/local/win_mac.rs +++ b/src/desktop/local/win_mac.rs @@ -2,7 +2,35 @@ //! window of a multi-window app would need the Accessibility API (AXRaise); activating the //! app brings its key window forward, which covers the dialog-clicking use case. use crate::proto::*; -use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; +use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication, NSWorkspace}; +use objc2_foundation::{NSNumber, NSString}; + +/// The focused window, without enumerating the desktop. +/// +/// The application is found with `NSWorkspace`'s `activeApplication`, not `frontmostApplication`: +/// the latter is a KVO-observed property that only updates while an AppKit run loop is running, +/// and the daemon has none, so it would keep answering with whatever was in front when the +/// daemon started. `activeApplication` is resolved from the window server on each call. It is +/// deprecated, and xcap uses it for exactly this reason (xcap 0.9.8, +/// `src/macos/impl_window.rs`). +/// +/// Only then is a window list walked, for that one pid — `Desktop::windows` is expensive here, +/// because xcap's window handle stores only an id, so every accessor (`title`, `app_name`, +/// `pid`, the rect) re-runs `CGWindowListCopyWindowInfo` over every on-screen window. +pub fn focused_window() -> Result> { + let key = NSString::from_str("NSApplicationProcessIdentifier"); + let workspace = NSWorkspace::sharedWorkspace(); + #[allow(deprecated)] + let active = workspace.activeApplication(); + let pid = active + .and_then(|dict| dict.valueForKey(&key)) + .and_then(|pid| pid.downcast::().ok()) + .map(|pid| pid.intValue()); + match pid { + Some(pid) if pid > 0 => super::capture::window_of_pid(pid as u32), + _ => Ok(None), + } +} pub fn focus(w: &Window) -> Result<()> { let pid = w.pid as i32; diff --git a/src/desktop/local/win_windows.rs b/src/desktop/local/win_windows.rs index ecd864c..43b197f 100644 --- a/src/desktop/local/win_windows.rs +++ b/src/desktop/local/win_windows.rs @@ -5,16 +5,77 @@ use crate::proto::*; use std::ffi::c_void; use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; -use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId}; +use windows::Win32::System::Threading::{ + AttachThreadInput, GetCurrentThreadId, OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + QueryFullProcessImageNameW, +}; use windows::Win32::UI::HiDpi::{DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext}; use windows::Win32::UI::Input::KeyboardAndMouse::{ INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK, MOUSEINPUT, SendInput, }; use windows::Win32::UI::WindowsAndMessaging::{ - BringWindowToTop, GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, IsIconic, SM_CXVIRTUALSCREEN, - SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SMTO_ABORTIFHUNG, SMTO_BLOCK, SW_RESTORE, - SendMessageTimeoutW, SetForegroundWindow, ShowWindow, WM_NULL, + BringWindowToTop, GetForegroundWindow, GetSystemMetrics, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, + GetWindowThreadProcessId, IsIconic, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + SMTO_ABORTIFHUNG, SMTO_BLOCK, SW_RESTORE, SendMessageTimeoutW, SetForegroundWindow, ShowWindow, WM_NULL, }; +use windows::core::PWSTR; + +/// The foreground window via `GetForegroundWindow`: one handle, one title read, one process +/// name lookup. Orders of magnitude cheaper than enumerating every top-level window. +pub fn focused_window() -> Result> { + // SAFETY: plain Win32 queries on a handle we just obtained; every call tolerates a stale + // or NULL handle by returning zero/error, which we map to None. + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return Ok(None); + } + let len = GetWindowTextLengthW(hwnd); + let mut buf = vec![0u16; (len.max(0) as usize) + 1]; + let n = GetWindowTextW(hwnd, &mut buf); + let title = String::from_utf16_lossy(&buf[..n.max(0) as usize]); + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + let app = process_image_name(pid).unwrap_or_default(); + let mut r = windows::Win32::Foundation::RECT::default(); + let _ = GetWindowRect(hwnd, &mut r); + Ok(Some(Window { + id: hwnd.0 as usize as u64, + pid, + app, + title, + rect: Rect { + x: r.left, + y: r.top, + w: (r.right - r.left).max(0) as u32, + h: (r.bottom - r.top).max(0) as u32, + }, + focused: true, + minimized: IsIconic(hwnd).as_bool(), + })) + } +} + +/// `foo.exe` → `foo`, matching what xcap reports as the app name. +fn process_image_name(pid: u32) -> Option { + if pid == 0 { + return None; + } + // SAFETY: the handle is closed before returning; the buffer length is passed alongside it. + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buf = vec![0u16; 1024]; + let mut len = buf.len() as u32; + let ok = QueryFullProcessImageNameW(h, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len).is_ok(); + let _ = windows::Win32::Foundation::CloseHandle(h); + if !ok { + return None; + } + let full = String::from_utf16_lossy(&buf[..len as usize]); + let file = full.rsplit(['\\', '/']).next().unwrap_or(&full); + Some(file.strip_suffix(".exe").or_else(|| file.strip_suffix(".EXE")).unwrap_or(file).to_string()) + } +} /// Make every metric and capture in this process use physical pixels, so `GetSystemMetrics` /// agrees with the physical geometry xcap reports. Call once at startup, before any UI call. diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index 6c675e7..84bb174 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -17,6 +17,14 @@ pub trait Desktop: Send + Sync { async fn clipboard_get(&self) -> Result; async fn clipboard_set(&self, text: String) -> Result<()>; + /// The window that currently has focus, for audit context. This is not a new capability + /// (it is a subset of [`Desktop::windows`]) and is not exposed on the wire; it exists so the + /// daemon can record "what was in front" per action without paying for a full enumeration. + /// Backends override it with a direct query where the platform has one. + async fn focused_window(&self) -> Result> { + Ok(self.windows().await?.into_iter().find(|w| w.focused)) + } + async fn state(&self) -> Result { Ok(State { displays: self.displays().await?, windows: self.windows().await? }) } diff --git a/src/doctor.rs b/src/doctor.rs index cb4703e..fd99836 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -143,6 +143,20 @@ pub async fn run(request_permissions: bool) -> anyhow::Result { ); } } + let t0 = std::time::Instant::now(); + match desk.focused_window().await { + Ok(w) => line( + None, + "focused window", + format!( + "{} in {:?} (looked up before every audited action; `[serve.audit].window_titles = false` to skip)", + w.map(|w| crate::server::audit::sanitize(&crate::server::audit::describe_window(&w))) + .unwrap_or_else(|| "none".into()), + t0.elapsed() + ), + ), + Err(e) => line(None, "focused window", format!("{e}")), + } match desk.windows().await { Ok(ws) => line( Some(true), diff --git a/src/main.rs b/src/main.rs index ce11c59..64263b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -343,7 +343,15 @@ async fn run(cli: Cli) -> Result<()> { let who = clean(&e.login.clone().or(e.node.clone()).unwrap_or_else(|| e.peer.clone())); let what = clean(&e.action.clone().unwrap_or_else(|| format!("{} {}", e.method, e.path))); let detail = e.detail.as_deref().map(|d| format!(" ({})", clean(d))).unwrap_or_default(); - println!("{:<24} {:<28} {:<7} {:<4} {what}{detail}", e.ts, who, e.outcome, e.status); + let window = e.window.as_deref().map(|w| format!(" [{}]", clean(w))).unwrap_or_default(); + // Entries can arrive from the network, so the "hash" is untrusted text: + // clean it and take characters, never bytes. + let hash = e + .screenshot_sha256 + .as_deref() + .map(|h| format!(" sha256:{}", clean(h).chars().take(12).collect::())) + .unwrap_or_default(); + println!("{:<24} {:<28} {:<7} {:<4} {what}{window}{hash}{detail}", e.ts, who, e.outcome, e.status); } if entries.is_empty() { eprintln!("no entries in {}", path.display()); diff --git a/src/server/audit.rs b/src/server/audit.rs index e65eda5..6d296fb 100644 --- a/src/server/audit.rs +++ b/src/server/audit.rs @@ -38,6 +38,38 @@ pub struct Entry { pub detail: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ms: Option, + /// Focused window when the request arrived, as `app: title`, so the log shows what the + /// action was aimed at. Omitted when `[serve.audit].window_titles = false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, + /// SHA-256 (hex) of the image bytes returned by a screenshot request, so a saved screenshot + /// can be matched to the exact audit line that produced it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub screenshot_sha256: Option, +} + +/// Longest window title kept in the log; titles can carry document names, URLs and mail +/// subjects, and the log is not the place for a whole one. +const MAX_TITLE: usize = 160; + +/// `app: title` for one window, truncated to [`MAX_TITLE`] characters. +pub fn describe_window(w: &crate::proto::Window) -> String { + let mut s = if w.app.is_empty() { w.title.clone() } else { format!("{}: {}", w.app, w.title) }; + if s.chars().count() > MAX_TITLE { + s = s.chars().take(MAX_TITLE - 1).collect::() + "…"; + } + s +} + +/// Lower-case hex SHA-256 of `bytes`. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest; + let d = sha2::Sha256::digest(bytes); + let mut s = String::with_capacity(64); + for b in d { + s.push_str(&format!("{b:02x}")); + } + s } /// Replace control characters (terminal escapes, newlines) so a hostile chord or window title @@ -63,9 +95,23 @@ impl Entry { status: 200, detail: None, ms: None, + window: None, + screenshot_sha256: None, } } + /// Window titles come from arbitrary applications: sanitize them like any other string + /// that lands in the log. + pub fn window(mut self, w: Option) -> Self { + self.window = w.map(|w| sanitize(&w)); + self + } + + pub fn screenshot_hash(mut self, bytes: &[u8]) -> Self { + self.screenshot_sha256 = Some(sha256_hex(bytes)); + self + } + pub fn action(mut self, a: impl AsRef) -> Self { self.action = Some(sanitize(a.as_ref())); self @@ -96,8 +142,17 @@ impl Entry { for f in [&mut self.ts, &mut self.host, &mut self.peer, &mut self.method, &mut self.path, &mut self.outcome] { *f = sanitize(f); } - for v in - [&mut self.via, &mut self.login, &mut self.node, &mut self.action, &mut self.detail].into_iter().flatten() + for v in [ + &mut self.via, + &mut self.login, + &mut self.node, + &mut self.action, + &mut self.detail, + &mut self.window, + &mut self.screenshot_sha256, + ] + .into_iter() + .flatten() { *v = sanitize(v); } @@ -120,11 +175,18 @@ pub struct Audit { /// Stamped into entries that do not name a host yet. host: String, stream: Option, + window_titles: bool, } impl Audit { pub fn disabled() -> Self { - Self { sink: None, path: None, host: String::new(), stream: None } + Self { sink: None, path: None, host: String::new(), stream: None, window_titles: false } + } + + /// Whether handlers should look up the focused window for the log. False when the log is + /// off entirely, so the lookup is not paid for when nothing records it. + pub fn wants_window_titles(&self) -> bool { + self.sink.is_some() && self.window_titles } /// Open the audit file named by `cfg`. `host` is this machine's node name; it is written into @@ -150,6 +212,7 @@ impl Audit { path: Some(path), host: host.to_string(), stream: None, + window_titles: cfg.window_titles, }) } @@ -278,6 +341,56 @@ fn now_rfc3339() -> String { mod tests { use super::*; + #[test] + fn sha256_matches_known_vector() { + assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert_eq!(sha256_hex(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + #[test] + fn window_is_described_and_truncated() { + use crate::proto::{Rect, Window}; + let mk = |app: &str, title: &str| Window { + id: 1, + pid: 1, + app: app.into(), + title: title.into(), + rect: Rect { x: 0, y: 0, w: 1, h: 1 }, + focused: true, + minimized: false, + }; + assert_eq!(describe_window(&mk("Firefox", "Inbox")), "Firefox: Inbox"); + assert_eq!(describe_window(&mk("", "Untitled")), "Untitled"); + let long = "x".repeat(500); + let d = describe_window(&mk("Firefox", &long)); + assert_eq!(d.chars().count(), MAX_TITLE); + assert!(d.ends_with('…')); + } + + #[test] + fn window_titles_are_sanitized() { + let e = Entry::new("100.64.0.1", None, "POST", "/v1/act").window(Some("Term: \u{1b}]0;evil\u{7}".into())); + let w = e.window.unwrap(); + assert!(!w.contains('\u{1b}') && !w.contains('\u{7}'), "{w:?}"); + } + + #[test] + fn entry_serializes_new_fields_only_when_set() { + let e = Entry::new("100.64.0.1", None, "GET", "/v1/screenshot"); + let j = serde_json::to_string(&e).unwrap(); + assert!(!j.contains("window") && !j.contains("screenshot_sha256")); + let e = e.window(Some("Terminal: ~".into())).screenshot_hash(b"abc"); + let j = serde_json::to_string(&e).unwrap(); + assert!(j.contains("\"window\":\"Terminal: ~\"")); + assert!(j.contains("\"screenshot_sha256\":\"ba7816bf")); + // Old logs without the fields still parse. + let old: Entry = serde_json::from_str( + r#"{"ts":"t","peer":"p","method":"GET","path":"/v1/state","outcome":"ok","status":200}"#, + ) + .unwrap(); + assert_eq!(old.window, None); + } + #[test] fn writes_and_rotates() { let dir = std::env::temp_dir().join(format!("rdc-audit-test-{}", std::process::id())); diff --git a/src/server/routes.rs b/src/server/routes.rs index 0c3166b..45dc428 100644 --- a/src/server/routes.rs +++ b/src/server/routes.rs @@ -13,36 +13,65 @@ use axum::{ use serde::Deserialize; use std::time::Instant; +/// How long the focused-window lookup may take before the audit line is written without it. +/// Short on purpose: it runs before every screenshot, input, focus and clipboard call. +const FOCUS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250); + fn status_of(e: &RdcError) -> u16 { auth::error_response(e).status().as_u16() } -/// Run a capability-gated handler body and write the audit line for it. +/// What a handler is doing, for the audit line. +struct Call<'a> { + method: &'a str, + path: &'a str, + cap: Capability, + action: Option, + /// Record the focused window *before* the body runs, i.e. the context the action was aimed + /// at rather than whatever it left behind. + with_window: bool, +} + +/// Run a capability-gated handler body and write the audit line for it. `finish` gets the +/// successful result so a handler can attach result-derived facts (the screenshot hash). async fn audited( s: &AppState, id: &Identity, - method: &str, - path: &str, - cap: Capability, - action: Option, + call: Call<'_>, f: F, + finish: impl FnOnce(&T, Entry) -> Entry, ) -> Result where F: std::future::Future>, { let started = Instant::now(); - let mut entry = Entry::new(&id.ip, Some(id), method, path); - if let Some(a) = action { + let mut entry = Entry::new(&id.ip, Some(id), call.method, call.path); + if let Some(a) = call.action { entry = entry.action(a); } - if let Err(e) = auth::require(id, cap) { + if let Err(e) = auth::require(id, call.cap) { tracing::warn!(who = id.label(), error = %e, "forbidden"); s.audit.record(entry.denied(status_of(&e), e.message()).took(started)); return Err(e); } + if call.with_window && s.audit.wants_window_titles() { + // Best effort, and bounded: the audit line is context, never a reason to delay or fail + // the request. One timeout here covers every backend — a wedged compositor, a window + // server that will not answer, a `hyprctl` that never returns. + let t0 = Instant::now(); + let w = match tokio::time::timeout(FOCUS_LOOKUP_TIMEOUT, s.desktop.focused_window()).await { + Ok(r) => r.ok().flatten(), + Err(_) => { + tracing::debug!("focused-window lookup did not answer within {FOCUS_LOOKUP_TIMEOUT:?}"); + None + } + }; + tracing::debug!(us = t0.elapsed().as_micros(), found = w.is_some(), "audit focused-window lookup"); + entry = entry.window(w.as_ref().map(super::audit::describe_window)); + } let r = f.await; match &r { - Ok(_) => s.audit.record(entry.took(started)), + Ok(v) => s.audit.record(finish(v, entry).took(started)), Err(e) => s.audit.record(entry.error(status_of(e), e.message()).took(started)), } r @@ -73,7 +102,8 @@ impl IntoResponse for Api { } async fn state_h(State(s): State, Extension(id): Extension) -> Api { - Api(audited(&s, &id, "GET", "/v1/state", Capability::View, None, s.desktop.state()).await) + let call = Call { method: "GET", path: "/v1/state", cap: Capability::View, action: None, with_window: false }; + Api(audited(&s, &id, call, s.desktop.state(), |_, e| e).await) } #[derive(Deserialize)] @@ -120,7 +150,9 @@ async fn screenshot_h( Some(m) => format!("screenshot {display} {} max={m}", format.ext()), None => format!("screenshot {display} {}", format.ext()), }; - match audited(&s, &id, "GET", "/v1/screenshot", Capability::View, Some(action), s.desktop.screenshot(req)).await { + let call = + Call { method: "GET", path: "/v1/screenshot", cap: Capability::View, action: Some(action), with_window: true }; + match audited(&s, &id, call, s.desktop.screenshot(req), |shot, e| e.screenshot_hash(&shot.data)).await { Ok(shot) => { let mut h = HeaderMap::new(); h.insert(header::CONTENT_TYPE, HeaderValue::from_static(shot.format.mime())); @@ -155,23 +187,21 @@ async fn act_h( Action::ClipboardSet { .. } => Capability::Clipboard, }; let desc = a.describe(); - Api(audited(&s, &id, "POST", "/v1/act", cap, Some(desc), s.desktop.act(a)) - .await - .map(|_| serde_json::json!({ "ok": true }))) + let call = Call { method: "POST", path: "/v1/act", cap, action: Some(desc), with_window: true }; + Api(audited(&s, &id, call, s.desktop.act(a), |_, e| e).await.map(|_| serde_json::json!({ "ok": true }))) } async fn clipboard_h(State(s): State, Extension(id): Extension) -> Api { - Api(audited( - &s, - &id, - "GET", - "/v1/clipboard", - Capability::Clipboard, - Some("clipboard.get".into()), - s.desktop.clipboard_get(), - ) - .await - .map(|text| serde_json::json!({ "text": text }))) + let call = Call { + method: "GET", + path: "/v1/clipboard", + cap: Capability::Clipboard, + action: Some("clipboard.get".into()), + with_window: true, + }; + Api(audited(&s, &id, call, s.desktop.clipboard_get(), |_, e| e) + .await + .map(|text| serde_json::json!({ "text": text }))) } async fn whoami_h(State(s): State, Extension(id): Extension) -> Json { diff --git a/src/server/tests.rs b/src/server/tests.rs index bf7e0da..dfbc1c0 100644 --- a/src/server/tests.rs +++ b/src/server/tests.rs @@ -1,7 +1,7 @@ //! Router-level tests: the real axum stack with a fake desktop and a fake identity table. //! These cover the security gate end to end without a network, a display or tailscaled. -use super::audit::{Audit, Entry, tail}; +use super::audit::{Audit, Entry, sha256_hex, tail}; use super::auth::{Allowlist, Auth, HostAllow, Identify}; use super::{AppState, routes}; use crate::config::{AuditConfig, ResolvedGrant}; @@ -18,13 +18,35 @@ use http_body_util::BodyExt; use std::collections::{BTreeSet, HashMap}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tower::ServiceExt; -/// Records what the daemon was asked to do. +/// Records what the daemon was asked to do, and reports whatever windows a test installs. #[derive(Default)] struct FakeDesktop { actions: Mutex>, + windows: Mutex>, + /// How many times the window list was read; the default `focused_window` goes through it. + window_queries: AtomicUsize, + /// Make every window lookup fail, as a backend with no window support would. + windows_fail: AtomicBool, +} + +impl FakeDesktop { + /// Stand in for an application with a hostile name: control characters that would rewrite + /// the audit file or the terminal reading it. + fn focus_a_window_titled(&self, app: &str, title: &str) { + *self.windows.lock().unwrap() = vec![Window { + id: 7, + pid: 99, + app: app.into(), + title: title.into(), + rect: Rect { x: 0, y: 0, w: 100, h: 100 }, + focused: true, + minimized: false, + }]; + } } #[async_trait] @@ -48,10 +70,17 @@ impl Desktop for FakeDesktop { }) } async fn windows(&self) -> Result> { - Ok(vec![]) + self.window_queries.fetch_add(1, Ordering::SeqCst); + if self.windows_fail.load(Ordering::SeqCst) { + return Err(RdcError::Unsupported("window listing".into())); + } + Ok(self.windows.lock().unwrap().clone()) } async fn focus(&self, target: WindowTarget) -> Result<()> { self.actions.lock().unwrap().push(format!("focus {target:?}")); + // Focusing changes which window is in front, so a lookup made after the action would + // see the new one. + self.focus_a_window_titled("Safari", "after focus"); Ok(()) } async fn input(&self, action: InputAction) -> Result<()> { @@ -99,6 +128,10 @@ struct Harness { } fn harness(name: &str) -> Harness { + harness_cfg(name, true) +} + +fn harness_cfg(name: &str, window_titles: bool) -> Harness { let mut table = HashMap::new(); table.insert(ALICE.parse().unwrap(), ident(Some("alice@example.com"), "alice-laptop", &[])); table.insert(MONITOR.parse().unwrap(), ident(None, "monitor-1", &["tag:monitor"])); @@ -113,9 +146,11 @@ fn harness(name: &str) -> Harness { let dir = std::env::temp_dir().join(format!("rdc-router-test-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let audit_path = dir.join("audit.jsonl"); - let audit = - Audit::open(&AuditConfig { enabled: true, path: Some(audit_path.clone()), ..Default::default() }, "studio-mac") - .unwrap(); + let audit = Audit::open( + &AuditConfig { enabled: true, path: Some(audit_path.clone()), window_titles, ..Default::default() }, + "studio-mac", + ) + .unwrap(); let desktop = Arc::new(FakeDesktop::default()); let state = AppState { desktop: desktop.clone(), auth: Arc::new(auth), audit: Arc::new(audit) }; Harness { app: routes::router(state), desktop, audit_path } @@ -129,6 +164,18 @@ async fn call( path: &str, body: Option<&str>, ) -> (StatusCode, String) { + let (status, bytes) = call_bytes(h, peer, host, method, path, body).await; + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +async fn call_bytes( + h: &Harness, + peer: &str, + host: &str, + method: &str, + path: &str, + body: Option<&str>, +) -> (StatusCode, Vec) { let mut req = Request::builder().method(method).uri(path).header("host", host); if body.is_some() { req = req.header("content-type", "application/json"); @@ -138,7 +185,7 @@ async fn call( let resp = h.app.clone().oneshot(req).await.unwrap(); let status = resp.status(); let bytes = resp.into_body().collect().await.unwrap().to_bytes(); - (status, String::from_utf8_lossy(&bytes).into_owned()) + (status, bytes.to_vec()) } fn audit_lines(h: &Harness) -> Vec { @@ -281,3 +328,136 @@ async fn hostile_values_cannot_break_the_audit_file() { let entries = audit_lines(&h); assert!(entries[0].action.as_deref().unwrap().contains("fake line")); } + +#[tokio::test] +async fn audited_actions_carry_the_focused_window_sanitised() { + let h = harness("window"); + // An application whose name and title are an attempt to inject a terminal escape and to + // forge a second audit line. + h.desktop.focus_a_window_titled("Term\u{1b}]0;pwned\u{7}", "notes.txt\n{\"outcome\":\"ok\"}"); + let (status, _) = call(&h, ALICE, "studio-mac.example.ts.net:7770", "POST", "/v1/act", Some(CLICK)).await; + assert_eq!(status, StatusCode::OK); + + let act = audit_lines(&h).into_iter().find(|e| e.path == "/v1/act").expect("the click was not audited"); + let window = act.window.expect("no window recorded on an input action"); + assert!(window.starts_with("Term"), "app and title should both be there: {window:?}"); + assert!(window.contains("notes.txt")); + assert!(!window.contains('\u{1b}') && !window.contains('\u{7}'), "escape survived: {window:?}"); + assert!(!window.contains('\n'), "a newline would forge a second line: {window:?}"); + // One request, one line: the injected JSON did not become an entry of its own. + assert_eq!(audit_lines(&h).iter().filter(|e| e.path == "/v1/act").count(), 1); +} + +#[tokio::test] +async fn window_titles_false_omits_the_window() { + let h = harness_cfg("no-titles", false); + h.desktop.focus_a_window_titled("Mail", "Re: salary review"); + let (status, _) = call(&h, ALICE, "studio-mac.example.ts.net:7770", "POST", "/v1/act", Some(CLICK)).await; + assert_eq!(status, StatusCode::OK); + + let act = audit_lines(&h).into_iter().find(|e| e.path == "/v1/act").unwrap(); + assert_eq!(act.window, None, "window_titles = false must keep titles out of the log"); + // The rest of the line is unaffected. + assert_eq!(act.outcome, "ok"); + assert!(act.action.unwrap().contains("click")); +} + +#[tokio::test] +async fn screenshot_hash_matches_the_bytes_that_were_sent() { + let h = harness("shot-hash"); + let (status, body) = call_bytes(&h, ALICE, "studio-mac.example.ts.net:7770", "GET", "/v1/screenshot", None).await; + assert_eq!(status, StatusCode::OK); + + let shot = audit_lines(&h).into_iter().find(|e| e.path == "/v1/screenshot").unwrap(); + assert_eq!( + shot.screenshot_sha256.as_deref(), + Some(sha256_hex(&body).as_str()), + "the logged hash must be of the image the caller received" + ); + // Other routes have nothing to hash. + let _ = call(&h, ALICE, "studio-mac.example.ts.net:7770", "GET", "/v1/clipboard", None).await; + let clip = audit_lines(&h).into_iter().find(|e| e.path == "/v1/clipboard").unwrap(); + assert_eq!(clip.screenshot_sha256, None); +} + +#[tokio::test] +async fn every_audited_desktop_route_records_the_window() { + let h = harness("window-routes"); + h.desktop.focus_a_window_titled("Editor", "draft.md"); + let host = "studio-mac.example.ts.net:7770"; + let (st, _) = call(&h, ALICE, host, "GET", "/v1/screenshot", None).await; + assert_eq!(st, StatusCode::OK); + let (st, _) = call(&h, ALICE, host, "GET", "/v1/clipboard", None).await; + assert_eq!(st, StatusCode::OK); + let (st, _) = call(&h, ALICE, host, "POST", "/v1/act", Some(r#"{"kind":"clipboard_set","text":"x"}"#)).await; + assert_eq!(st, StatusCode::OK); + let (st, _) = call(&h, ALICE, host, "POST", "/v1/act", Some(CLICK)).await; + assert_eq!(st, StatusCode::OK); + + let lines = audit_lines(&h); + assert_eq!(lines.len(), 4, "{lines:?}"); + for e in &lines { + let w = e.window.as_deref().unwrap_or_else(|| panic!("no window on {} {:?}", e.path, e.action)); + assert!(w.contains("Editor") && w.contains("draft.md"), "{w:?}"); + } + // Whoami and health touch no desktop and look nothing up. + let before = h.desktop.window_queries.load(Ordering::SeqCst); + call(&h, ALICE, host, "GET", "/v1/whoami", None).await; + call(&h, ALICE, host, "GET", "/health", None).await; + assert_eq!(h.desktop.window_queries.load(Ordering::SeqCst), before); +} + +#[tokio::test] +async fn the_window_is_the_one_in_front_before_the_action() { + let h = harness("window-before"); + h.desktop.focus_a_window_titled("Terminal", "before focus"); + let body = r#"{"kind":"focus","by":"app","value":"Safari"}"#; + let (st, _) = call(&h, ALICE, "studio-mac.example.ts.net:7770", "POST", "/v1/act", Some(body)).await; + assert_eq!(st, StatusCode::OK); + + let act = audit_lines(&h).into_iter().find(|e| e.path == "/v1/act").unwrap(); + let w = act.window.unwrap(); + assert!(w.contains("Terminal") && w.contains("before focus"), "recorded the result, not the target: {w:?}"); +} + +#[tokio::test] +async fn a_failed_window_lookup_does_not_fail_the_request() { + let h = harness("window-fails"); + h.desktop.windows_fail.store(true, Ordering::SeqCst); + let host = "studio-mac.example.ts.net:7770"; + let (st, body) = call(&h, ALICE, host, "POST", "/v1/act", Some(CLICK)).await; + assert_eq!(st, StatusCode::OK, "{body}"); + let (st, _) = call(&h, ALICE, host, "GET", "/v1/screenshot", None).await; + assert_eq!(st, StatusCode::OK); + + assert_eq!(h.desktop.actions.lock().unwrap().clone(), vec!["input.click 10,10 Left x1".to_string()]); + let lines = audit_lines(&h); + assert_eq!(lines.len(), 2); + for e in &lines { + assert_eq!(e.outcome, "ok", "{e:?}"); + assert_eq!(e.window, None, "a failed lookup is recorded as no window"); + } + assert!(lines[1].screenshot_sha256.is_some(), "the hash does not depend on the window lookup"); +} + +#[tokio::test] +async fn denied_requests_never_look_up_the_window() { + let h = harness("window-denied"); + h.desktop.focus_a_window_titled("Mail", "Re: salary review"); + let host = "studio-mac.example.ts.net:7770"; + let (st, _) = call(&h, MONITOR, host, "POST", "/v1/act", Some(CLICK)).await; + assert_eq!(st, StatusCode::FORBIDDEN); + let (st, _) = call(&h, MONITOR, host, "GET", "/v1/clipboard", None).await; + assert_eq!(st, StatusCode::FORBIDDEN); + let (st, _) = call(&h, STRANGER, host, "GET", "/v1/screenshot", None).await; + assert_eq!(st, StatusCode::FORBIDDEN); + + assert_eq!( + h.desktop.window_queries.load(Ordering::SeqCst), + 0, + "a refused caller learned nothing about the desktop" + ); + let lines = audit_lines(&h); + assert!(!lines.is_empty()); + assert!(lines.iter().all(|e| e.window.is_none()), "{lines:?}"); +}