Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ builds the GitHub release body from the matching `## <version>` 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
Expand Down
75 changes: 74 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }
Expand Down
7 changes: 5 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
11 changes: 11 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ pub struct AuditConfig {
/// which identifies the sender through Tailscale.
#[serde(default)]
pub stream_token: Option<String>,
/// 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 {
Expand All @@ -226,6 +231,7 @@ impl Default for AuditConfig {
keep: default_audit_keep(),
stream: None,
stream_token: None,
window_titles: true,
}
}
}
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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"));
Expand Down
76 changes: 58 additions & 18 deletions src/desktop/local/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,33 +43,73 @@ pub fn displays() -> Result<Vec<Display>> {
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<Option<Window>> {
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<Vec<Window>> {
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<Option<Window>> {
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<Option<Window>> {
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<Monitor> {
let mons = Monitor::all().map_err(xe)?;
Expand Down
Loading
Loading