agentmux_mcp/window_capture.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Window capture and discovery, plus the audit log both write.
5//!
6//! Split out of `main.rs` (audit step 17,
7//! `docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md`) — it was the
8//! largest single cluster in that file and is one concern: find AgentMux
9//! windows, decide what this agent is allowed to capture (`CaptureTier`),
10//! capture it, and record what was captured.
11//!
12//! Bodies are unchanged from `main.rs`; the only edits were `pub(crate)` on
13//! the items `call_tool` and the tests name. `main.rs` glob-imports this
14//! module, so those call sites — and `mod tests`'s `use super::*` — resolve
15//! exactly as they did before.
16
17use std::time::Duration;
18
19use anyhow::Result;
20use serde_json::{json, Value};
21
22use crate::agent_slug;
23
24/// Where `CaptureWindow` writes its PNGs: `<AgentMux root>/tmp/capture-window`,
25/// not the shared OS temp dir — reagent P2 on this tool's own PR (#2709
26/// round 1): `std::env::temp_dir()` is world-readable on a multi-user host,
27/// and CaptureWindow can capture arbitrary OS windows (not just AgentMux's own
28/// pane), so a captured image could leak to other local users.
29///
30/// The root comes from `agentmux_common::data_paths::agentmux_root()`, the one
31/// resolver srv uses too (#3372). This used to be a private copy that read only
32/// `AGENTMUX_DATA_HOME` and fell back to `/` with no home dir; it now honours
33/// `AGENTMUX_HOME_OVERRIDE` first and errors instead.
34pub(crate) fn capture_window_dir() -> Result<std::path::PathBuf, String> {
35 Ok(agentmux_common::data_paths::agentmux_root()?.join("tmp/capture-window"))
36}
37
38pub(crate) const CAPTURE_RETENTION: std::time::Duration = std::time::Duration::from_secs(60 * 60);
39
40/// Same bug class, same fix, as `agentmux-srv`'s `prune_old_screenshots`
41/// (`ui_handlers.rs`, reagent P2, PR #2662) — reapplied here per reagent's
42/// review of this tool's own PR (#2709 round 1), which found it wasn't
43/// reused. Best-effort, on the write path, PNG-only.
44pub(crate) fn prune_old_captures(dir: &std::path::Path) {
45 let Ok(entries) = std::fs::read_dir(dir) else {
46 return;
47 };
48 let now = std::time::SystemTime::now();
49 for entry in entries.flatten() {
50 let path = entry.path();
51 if path.extension().and_then(|e| e.to_str()) != Some("png") {
52 continue;
53 }
54 let Ok(metadata) = entry.metadata() else { continue };
55 let Ok(modified) = metadata.modified() else { continue };
56 let Ok(age) = now.duration_since(modified) else { continue };
57 if age > CAPTURE_RETENTION {
58 let _ = std::fs::remove_file(&path);
59 }
60 }
61}
62
63/// Max ancestor hops to walk from this MCP process before giving up —
64/// bounds the fallback search (see `own_instance_pids`'s doc comment for why
65/// this is a fallback, not the primary signal), while never walking so far
66/// up the tree (e.g. to `explorer.exe`) that "same instance" degrades into
67/// "everything on the desktop."
68pub(crate) const OWN_INSTANCE_ANCESTOR_HOPS: usize = 8;
69
70/// PIDs `CaptureWindow` must treat as "this agent's own AgentMux instance"
71/// and always exclude — reagent P0 (PR #2709 round 3).
72///
73/// **Primary signal: `AGENTMUX_APP_PATH`.** Every process belonging to this
74/// exact running instance (portable build or install) is launched from
75/// under that one directory — confirmed directly (not assumed): this
76/// agent's own host process's `Process::exe()` path
77/// (`...\agentmux-0.55.18+....-x64-portable\runtime\agentmux-0.55.18.exe`)
78/// starts with `AGENTMUX_APP_PATH`
79/// (`...\agentmux-0.55.18+....-x64-portable`) exactly. This is more precise
80/// than matching on version number alone — it correctly tells apart two
81/// separate instances that happen to share a version (e.g. two portable
82/// builds of the same release), which a version-string match would
83/// conflate.
84///
85/// **Fallback signal: bounded process-ancestor walk.** Kept as a second,
86/// additive layer (union, not replacement) for the case `AGENTMUX_APP_PATH`
87/// isn't set (confirmed present for `AGENTMUX_RUNTIME_MODE=portable`; not
88/// separately confirmed for `task dev` instances). **This alone is not
89/// reliable** — verified directly by testing: Windows recycles PIDs and a
90/// process's recorded parent-PID can point at an already-exited process, so
91/// `sys.process(stale_ppid)` returns `None` and the walk silently stops
92/// short of the real ancestor chain. Confirmed this exact failure in
93/// testing (the walk never reached this instance's own host process). Kept
94/// only as defense-in-depth alongside the path-based signal, never alone.
95///
96/// Fails safe: an unresolvable `pid()` on a *candidate* window (checked by
97/// the caller, `CaptureWindow`'s handler) is treated as "assume it's mine,
98/// exclude it" — so a window this function's own signals can't positively
99/// place is still excluded, not silently let through.
100pub(crate) fn own_instance_pids() -> std::collections::HashSet<u32> {
101 let sys = sysinfo::System::new_all();
102 let mut result = std::collections::HashSet::new();
103
104 if let Ok(app_path) = std::env::var("AGENTMUX_APP_PATH") {
105 if !app_path.is_empty() {
106 let app_path = std::path::Path::new(&app_path);
107 for (pid, proc) in sys.processes() {
108 if proc.exe().map(|e| e.starts_with(app_path)).unwrap_or(false) {
109 result.insert(pid.as_u32());
110 }
111 }
112 }
113 }
114
115 let my_pid = sysinfo::Pid::from(std::process::id() as usize);
116 let mut ancestors = vec![my_pid];
117 let mut current = my_pid;
118 for _ in 0..OWN_INSTANCE_ANCESTOR_HOPS {
119 let Some(proc) = sys.process(current) else { break };
120 let Some(parent) = proc.parent() else { break };
121 ancestors.push(parent);
122 current = parent;
123 }
124 result.extend(ancestors.iter().map(|p| p.as_u32()));
125 for (pid, proc) in sys.processes() {
126 let Some(ppid) = proc.parent() else { continue };
127 if !ancestors.contains(&ppid) {
128 continue;
129 }
130 if proc.name().to_string_lossy().to_lowercase().starts_with("agentmux") {
131 result.insert(pid.as_u32());
132 }
133 }
134 result
135}
136
137/// Capture tier for one target window — see
138/// `docs/specs/SPEC_AGENT_UNRESTRICTED_CAPTURE_WITH_ACCOUNTABILITY_2026_08_30.md`
139/// §3. Keyed on WHAT is being captured, never on who is asking: identity is
140/// already proven upstream (`sign_ui_automation_auth`), so the open question
141/// is what a proven identity may reach.
142///
143/// This replaces the previous binary `!is_self` exclusion. That rule came from
144/// `SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md` §6, which itself
145/// says cross-agent targeting *"if it's ever wanted at all"* should be a
146/// distinct capability — i.e. it defaulted closed because no mechanism existed
147/// to be selective, not because open had been judged wrong. The repo owner has
148/// since directed that agents be able to capture anything. This enum is that
149/// mechanism.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub(crate) enum CaptureTier {
152 /// T1 — another pane inside the caller's OWN instance. Its window hosts
153 /// every agent's pane, which is exactly what the old rule blocked.
154 SameInstance,
155 /// T2 — a different AgentMux instance owned by the same OS user.
156 OtherInstance,
157 /// T3 — a window owned by a DIFFERENT OS user. The one tier that crosses a
158 /// human rather than an agent boundary: that user never consented to this
159 /// app's agents, and no in-app notification can reach them.
160 OtherUser,
161 /// T4 — a non-AgentMux window owned by the same OS user (their browser,
162 /// their password manager). PR #2709 round 2 caught the original unscoped
163 /// tool capturing a KeePass window; allowed now, but always audited.
164 ForeignApp,
165}
166
167impl CaptureTier {
168 /// Phase 1 `allow` defaults (spec §3). Only T3 is closed, and only because
169 /// it is a human-to-human boundary — every agent-to-agent tier is open.
170 pub(crate) fn allowed(self) -> bool {
171 !matches!(self, CaptureTier::OtherUser)
172 }
173
174 pub(crate) fn label(self) -> &'static str {
175 match self {
176 CaptureTier::SameInstance => "T1-same-instance",
177 CaptureTier::OtherInstance => "T2-other-instance",
178 CaptureTier::OtherUser => "T3-other-user",
179 CaptureTier::ForeignApp => "T4-foreign-app",
180 }
181 }
182}
183
184/// The OS user id owning the calling process, used to place a target window on
185/// the near or far side of the T3 boundary.
186///
187/// Fails CLOSED, matching `own_instance_pids()`'s existing discipline: if this
188/// returns `None` every window resolves to `OtherUser` and capture is denied.
189/// An unresolvable owner is exactly the case where guessing "probably mine"
190/// would be how a cross-user capture slips through.
191pub(crate) fn current_user_id(sys: &sysinfo::System) -> Option<String> {
192 let me = sysinfo::get_current_pid().ok()?;
193 sys.process(me)
194 .and_then(|p| p.user_id())
195 .map(|u| u.to_string())
196}
197
198/// A completed capture. Richer than the bare message string it replaces so
199/// `audit_log_capture_window` can record WHAT was captured and at which tier,
200/// rather than only the caller's query and a success/failure flag.
201#[derive(Debug)]
202pub(crate) struct CaptureOutcome {
203 /// Human-facing tool result, returned to the caller verbatim.
204 pub(crate) message: String,
205 pub(crate) tier: CaptureTier,
206 /// Resolved target — `pid=N title="..."`.
207 pub(crate) target: String,
208 /// Hex SHA-256 of the saved PNG; `None` if hashing failed (best-effort).
209 pub(crate) image_sha256: Option<String>,
210}
211
212/// One top-level window visible to this process, with the metadata both
213/// `DiscoverWindows` and `CaptureWindow` need. Shared via
214/// `enumerate_agentmux_windows()` so the two tools can't drift on what counts
215/// as "AgentMux's own window", "the calling agent's own instance", or which
216/// tier a target falls in.
217pub(crate) struct AgentMuxWindowInfo {
218 pub(crate) window: xcap::Window,
219 pub(crate) pid: u32,
220 pub(crate) title: String,
221 pub(crate) exe_path: String,
222 pub(crate) is_self: bool,
223 /// Whether the owning process is an AgentMux process at all — T4 targets
224 /// are enumerated now, so this is no longer implied by presence in the list.
225 pub(crate) is_agentmux: bool,
226 pub(crate) tier: CaptureTier,
227}
228
229/// Enumerate every top-level OS window, AgentMux-owned or not, each tagged
230/// with the `CaptureTier` that decides whether it may be captured.
231///
232/// `is_agentmux` is matched via `app_name()`, not `title()` — AgentMux's own
233/// process names are version-stamped, e.g. `agentmux-0.55.18`, not a single
234/// fixed string, but they all share the `agentmux` prefix (reagent P1, PR
235/// #2709 round 2). That flag no longer decides *inclusion* — non-AgentMux
236/// windows are T4 capture targets — but it still gates disclosure: they stay
237/// out of `DiscoverWindows`' default listing and their titles are withheld
238/// from candidate/miss lists (`candidate_label`), which is what keeps round
239/// 2's KeePass-title leak closed now that they're enumerated at all.
240///
241/// `is_self` marks windows belonging to the calling agent's OWN instance
242/// (`own_instance_pids()`).
243///
244/// **Historical note (reagent P2 on PR #2845):** this comment previously said
245/// `is_self` windows were "hard-excluded downstream by `capture_window_impl`
246/// … the actual isolation boundary, not a nicety" (reagent P0, PR #2709
247/// round 3). That is no longer true and the reasoning has been superseded, not
248/// merely relaxed: the boundary it protected —
249/// `SPEC_AGENT_UI_AUTOMATION_CLICK_SCREENSHOT_2026_08_18.md` §6's own-pane-only
250/// default — was an unratified recommendation that defaulted closed because no
251/// mechanism existed to be selective. `is_self` now resolves to
252/// `CaptureTier::SameInstance`, which is allowed. See
253/// `SPEC_AGENT_UNRESTRICTED_CAPTURE_WITH_ACCOUNTABILITY_2026_08_30.md`.
254pub(crate) fn enumerate_agentmux_windows() -> Result<Vec<AgentMuxWindowInfo>> {
255 let windows =
256 xcap::Window::all().map_err(|e| anyhow::anyhow!("failed to enumerate windows: {e}"))?;
257 let own_pids = own_instance_pids();
258 let sys = sysinfo::System::new_all();
259 let me = current_user_id(&sys);
260 let mut out = Vec::new();
261 for window in windows {
262 let is_agentmux = window
263 .app_name()
264 .map(|a| a.to_lowercase().starts_with("agentmux"))
265 .unwrap_or(false);
266 // Non-AgentMux windows are no longer skipped — they are T4 targets.
267 // They stay out of `DiscoverWindows`' default listing (see its
268 // `include_foreign` arg) so ordinary discovery doesn't disclose the
269 // titles of a user's unrelated applications as a side effect.
270 let pid = window.pid().unwrap_or(0);
271 // Fails safe: an unresolvable pid is treated as "assume it's mine"
272 // rather than silently dropped, so `DiscoverWindows` still surfaces
273 // that the window exists instead of hiding it.
274 //
275 // That promise is real again as of reagentx P2 on PR #2845. Briefly it
276 // wasn't: an unresolvable OWNER resolves the window to T3, and
277 // `DiscoverWindows` was dropping non-allowed tiers outright, so such a
278 // window vanished from the listing even with `include_self`. It is now
279 // listed with `title`/`exe_path` redacted, which keeps the disclosure
280 // closed without reintroducing the hiding this comment warns against.
281 let is_self = pid == 0 || own_pids.contains(&pid);
282 let proc = sys.process(sysinfo::Pid::from(pid as usize));
283 let title = window.title().unwrap_or_default();
284 let exe_path = proc
285 .and_then(|p| p.exe())
286 .map(|p| p.display().to_string())
287 .unwrap_or_default();
288
289 // Tier resolution. The OS-user check runs FIRST and outranks
290 // everything else: a window owned by another human is T3 no matter
291 // what process owns it, including another AgentMux instance of theirs.
292 let owner = proc.and_then(|p| p.user_id()).map(|u| u.to_string());
293 let tier = match (&me, &owner) {
294 // Both known and different — the human boundary.
295 (Some(mine), Some(theirs)) if mine != theirs => CaptureTier::OtherUser,
296 // Either side unresolvable — fail closed, same discipline as
297 // `is_self` above. Guessing "probably mine" here is precisely how
298 // a cross-user capture would slip through.
299 (None, _) | (_, None) => CaptureTier::OtherUser,
300 _ if is_self => CaptureTier::SameInstance,
301 _ if is_agentmux => CaptureTier::OtherInstance,
302 _ => CaptureTier::ForeignApp,
303 };
304
305 out.push(AgentMuxWindowInfo {
306 window,
307 pid,
308 title,
309 exe_path,
310 is_self,
311 is_agentmux,
312 tier,
313 });
314 }
315 Ok(out)
316}
317
318/// Cheap heuristic for "this capture is probably a blank/unpainted frame,
319/// not a real render" — sample ~200 evenly-spaced pixels and check they're
320/// all within a small tolerance of the first one. Not real image analysis
321/// (a legitimately solid-color themed window would also trip this) —
322/// deliberately cheap and approximate, used only to decide whether a short
323/// bounded retry is worth attempting, and to set `likely_unrendered` as a
324/// hint, never as a hard failure.
325pub(crate) fn looks_unrendered(img: &image::RgbaImage) -> bool {
326 let pixels = img.as_raw();
327 if pixels.len() < 4 {
328 return true;
329 }
330 let (r0, g0, b0) = (pixels[0], pixels[1], pixels[2]);
331 let pixel_count = pixels.len() / 4;
332 let step = (pixel_count / 200).max(1);
333 let mut sampled = 0usize;
334 for i in (0..pixel_count).step_by(step) {
335 let idx = i * 4;
336 sampled += 1;
337 if pixels[idx].abs_diff(r0) > 8 || pixels[idx + 1].abs_diff(g0) > 8 || pixels[idx + 2].abs_diff(b0) > 8 {
338 return false;
339 }
340 }
341 sampled > 0
342}
343
344/// Label for a window in the AUDIT TRAIL.
345///
346/// Fuller than `candidate_label` where it can be — reagentx P2 on PR #2845
347/// correctly spotted that reusing the caller-facing label lost a T4 window's
348/// title on a post-resolution failure, giving foreign-app failures less audit
349/// detail than AgentMux ones for no reason.
350///
351/// But NOT unconditionally full, because that finding's premise — "the audit
352/// is server-side only" — does not hold here. The trail is an NDJSON file in
353/// this instance's own data dir, and agents have shell access; it was read
354/// with `tail` during this PR's own verification. It is therefore not a
355/// confidential sink.
356///
357/// So audit detail follows the ALLOW decision, not AgentMux-ness:
358/// - allowed tier → full title. The agent could capture the window and read
359/// the title off the pixels anyway, so recording it discloses nothing new.
360/// - withheld tier → pid and tier only. Recording a T3 title would hand the
361/// agent, via a file it can read, exactly the content the tier denied it —
362/// reopening through the log the same oracle closed in the title-miss path.
363pub(crate) fn audit_target_label(w: &AgentMuxWindowInfo) -> String {
364 if w.tier.allowed() {
365 format!("pid={} title={:?}", w.pid, w.title)
366 } else {
367 format!("pid={} {} <title withheld>", w.pid, w.tier.label())
368 }
369}
370
371/// Label for a window in a CALLER-FACING disambiguation/candidate list.
372///
373/// Stricter than `audit_target_label` above, because the audiences differ: this
374/// one is returned to the agent, so it withholds the TITLE of every
375/// non-AgentMux window — reagent P1 on PR #2845. Foreign windows became
376/// capturable with the tier model, so any list built from the capturable set
377/// would otherwise disclose a user's unrelated app titles (their browser,
378/// their password manager) as a side effect of a miss or an ambiguous match,
379/// bypassing `DiscoverWindows`' own `include_foreign` opt-in. The pid alone
380/// disambiguates, and is what the caller needs anyway.
381pub(crate) fn candidate_label(w: &AgentMuxWindowInfo) -> String {
382 if w.is_agentmux {
383 format!("pid={} title={:?}", w.pid, w.title)
384 } else {
385 format!("pid={} <non-AgentMux window; pass include_foreign to DiscoverWindows to identify it>", w.pid)
386 }
387}
388
389/// The actual `CaptureWindow` logic — window enumeration, own-instance and
390/// third-party-app exclusion, targeting (by pid or by title), capture, and
391/// save. Extracted to its own function (rather than living inline in the
392/// `"CaptureWindow" =>` match arm) so its `Result` can be captured once and
393/// unconditionally audit-logged by the caller before propagating — see
394/// `audit_log_capture_window`.
395///
396/// `index: None` with more than one `title_contains` match is an error
397/// listing every candidate, NOT a silent pick of match 0 — see
398/// docs/reports/REPORT_AGENT_SCREENSHOT_WINDOW_CONTROL_BLOCKERS_2026_08_24.md
399/// §1 for the real incident (an ambiguous match silently captured an
400/// unrelated, sensitive window) this specifically fixes. `index: Some(i)`
401/// is still honored directly against whatever matched, unchanged from the
402/// original behavior, for callers who already disambiguate explicitly.
403pub(crate) fn capture_window_impl(
404 title_contains: Option<&str>,
405 index: Option<usize>,
406 pid: Option<u32>,
407 // Filled the moment a target is RESOLVED, so a failure after that point —
408 // a denied T3 pid, a failed capture_image, a failed PNG write — is still
409 // audited with its tier and target instead of nulls (codex P2 on PR #2845).
410 // Without it the log cannot tell "no such window" apart from "blocked
411 // cross-user attempt", which is the entry a reviewer most wants to find.
412 resolved: &mut Option<(CaptureTier, String)>,
413) -> Result<CaptureOutcome> {
414 let all = enumerate_agentmux_windows()?;
415 // Tier gate replaces the old `!w.is_self` exclusion — see `CaptureTier`.
416 // Only T3 (a different OS user's window) is withheld; every agent-to-agent
417 // tier, including this instance's own window, is reachable.
418 let foreign: Vec<&AgentMuxWindowInfo> = all.iter().filter(|w| w.tier.allowed()).collect();
419
420 let target: &AgentMuxWindowInfo = if let Some(target_pid) = pid {
421 // A single host process can own multiple top-level windows sharing
422 // the same pid (`LAST_FOCUSED_BY_ROOT` in
423 // crates/cef/src/browser_pane/hwnd.rs, `find_all_own_windows` in
424 // crates/cef/src/commands/window/lifecycle.rs), so a plain `.find()`
425 // would capture whichever matching window enumerated first — possibly
426 // a pool/sub-window, not the one the caller meant. Same
427 // ambiguity-rejection shape as the title_contains branch below:
428 // `index` disambiguates, an unqualified ambiguous match does not.
429 let matches: Vec<&AgentMuxWindowInfo> = foreign
430 .iter()
431 .filter(|w| w.pid == target_pid)
432 .copied()
433 .collect();
434 if matches.is_empty() {
435 // `foreign` is already tier-filtered, so a withheld T3 window is
436 // absent from it and would otherwise be indistinguishable from a
437 // pid that doesn't exist — reported identically to the caller AND
438 // audited as `tier: null`. Look it up in the UNFILTERED set to
439 // separate "withheld" from "absent" (codex P2 / reagentx P2).
440 if let Some(blocked) = all.iter().find(|w| w.pid == target_pid) {
441 *resolved = Some((blocked.tier, audit_target_label(blocked)));
442 anyhow::bail!(
443 "window pid={target_pid} is {} — capture is withheld for windows owned \
444 by a different OS user; every other tier is available",
445 blocked.tier.label()
446 );
447 }
448 anyhow::bail!(
449 "no window found for pid {target_pid} — call DiscoverWindows to see \
450 current candidates"
451 );
452 }
453 match index {
454 Some(i) => *matches.get(i).ok_or_else(|| {
455 anyhow::anyhow!(
456 "index {i} out of range — only {} window(s) matched pid {target_pid}",
457 matches.len()
458 )
459 })?,
460 None if matches.len() == 1 => matches[0],
461 None => {
462 let candidates: Vec<String> = matches
463 .iter()
464 .map(|w| candidate_label(w))
465 .collect();
466 anyhow::bail!(
467 "{} windows matched pid {target_pid} — a single process can own \
468 multiple top-level windows; pass an explicit index (0-based) \
469 alongside pid to disambiguate. Candidates: {}",
470 matches.len(),
471 candidates.join("; ")
472 );
473 }
474 }
475 } else {
476 let title_contains = title_contains
477 .ok_or_else(|| anyhow::anyhow!("must provide either pid or title_contains"))?;
478 let needle = title_contains.to_lowercase();
479 let matches: Vec<&AgentMuxWindowInfo> = foreign
480 .iter()
481 .filter(|w| w.title.to_lowercase().contains(&needle))
482 .copied()
483 .collect();
484
485 if matches.is_empty() {
486 // A title matching ONLY a withheld window is recorded for the
487 // AUDIT — and deliberately changes nothing the caller sees.
488 //
489 // reagentx P1 on PR #2845: an earlier version bailed here with a
490 // distinguishable "exists but is T3" message. Withholding the
491 // literal title wasn't enough — differing *responses* are an
492 // existence oracle. An agent can probe substrings (binary-search
493 // over characters) and reconstruct a cross-user window's title
494 // without ever being granted capture, defeating the very boundary
495 // this tier exists to hold. Introduced while fixing the audit gap
496 // below it: making withheld-vs-absent legible to the auditor made
497 // it legible to the attacker too.
498 //
499 // The split that resolves it: the AUDIT is server-side and may
500 // know; the CALLER may not. So this records `resolved` and then
501 // falls through to the identical generic-miss message below —
502 // structurally the same code path and the same bytes either way,
503 // rather than two branches a future edit could let drift apart.
504 //
505 // The pid branch above is deliberately NOT symmetric: it does name
506 // the tier. A caller must already hold the pid to ask, `Discover
507 // Windows` never hands out T3 pids, and process ownership is
508 // already enumerable by anything with shell access — so the pid's
509 // existence is not a secret this tool is keeping, while a window
510 // TITLE is exactly the content it is.
511 if let Some(blocked) = all
512 .iter()
513 .find(|w| !w.tier.allowed() && w.title.to_lowercase().contains(&needle))
514 {
515 *resolved = Some((blocked.tier, audit_target_label(blocked)));
516 }
517 // Only lists AGENTMUX windows' titles, never every window on the
518 // desktop — reagent P2 (PR #2709 round 2): the original version
519 // dumped every visible window's title on any miss, which let a
520 // caller enumerate arbitrary window titles (confirmed in testing:
521 // it leaked a password manager's document title) with no real
522 // match required at all.
523 //
524 // The `is_agentmux` filter is load-bearing again as of the tier
525 // model — reagent P1 on PR #2845. `foreign` now includes T4
526 // non-AgentMux windows (they became capturable), so listing it
527 // wholesale would have re-opened exactly that leak, and would have
528 // bypassed `DiscoverWindows`' own `include_foreign` opt-in in the
529 // process: a caller could enumerate the user's desktop by
530 // deliberately missing.
531 let titles: Vec<&str> = foreign
532 .iter()
533 .filter(|w| w.is_agentmux)
534 .map(|w| w.title.as_str())
535 .filter(|t| !t.is_empty())
536 .collect();
537 if titles.is_empty() {
538 anyhow::bail!(
539 "no AgentMux window title contains {title_contains:?} — \
540 no other AgentMux windows are currently open"
541 );
542 }
543 anyhow::bail!(
544 "no AgentMux window title contains {title_contains:?}. \
545 Open AgentMux window titles: {}",
546 titles.join(", ")
547 );
548 }
549
550 match index {
551 Some(i) => *matches.get(i).ok_or_else(|| {
552 anyhow::anyhow!(
553 "index {i} out of range — only {} window(s) matched {title_contains:?}",
554 matches.len()
555 )
556 })?,
557 None if matches.len() == 1 => matches[0],
558 None => {
559 // The fix for blocker #1 in the report: an ambiguous
560 // match with no explicit index used to silently capture
561 // index 0 despite this tool's own docstring claiming it
562 // would list candidates instead. It now actually does.
563 let candidates: Vec<String> = matches
564 .iter()
565 .map(|w| candidate_label(w))
566 .collect();
567 anyhow::bail!(
568 "{} windows matched {title_contains:?} — pass an explicit index (0-based), \
569 or better, one of these pids directly (preferred: stable, unlike title). \
570 Candidates: {}",
571 matches.len(),
572 candidates.join("; ")
573 );
574 }
575 }
576 };
577
578 *resolved = Some((target.tier, audit_target_label(target)));
579
580 let title = target.title.clone();
581
582 // Short bounded retry for a freshly-created window that hasn't
583 // painted its first real frame yet — see looks_unrendered()'s doc
584 // comment. std::thread::sleep (not tokio::time::sleep): this fn is
585 // plain sync code called from the async dispatch path, and the
586 // bounded ~800ms worst case is a deliberate, request-scoped wait
587 // directly serving this call, not background work stealing the
588 // runtime from anything else.
589 let mut image = target
590 .window
591 .capture_image()
592 .map_err(|e| anyhow::anyhow!("capture failed: {e}"))?;
593 let mut likely_unrendered = looks_unrendered(&image);
594 let mut attempts = 1;
595 while likely_unrendered && attempts < 3 {
596 std::thread::sleep(Duration::from_millis(400));
597 image = target
598 .window
599 .capture_image()
600 .map_err(|e| anyhow::anyhow!("capture failed: {e}"))?;
601 likely_unrendered = looks_unrendered(&image);
602 attempts += 1;
603 }
604
605 let dir = capture_window_dir()
606 .map_err(|e| anyhow::anyhow!("cannot resolve the AgentMux root for captures: {e}"))?;
607 std::fs::create_dir_all(&dir)
608 .map_err(|e| anyhow::anyhow!("failed to create capture dir {}: {e}", dir.display()))?;
609 let path = dir.join(format!("{}.png", uuid::Uuid::new_v4()));
610 image
611 .save(&path)
612 .map_err(|e| anyhow::anyhow!("failed to save capture to {}: {e}", path.display()))?;
613 // Unbounded growth over repeated/looped calls otherwise — same bug
614 // class as UIScreenshot's own screenshot dir (reagent P2, PR #2662)
615 // and reagent's own review of this PR (round 1) pointing out that
616 // fix wasn't reused here. Same fix, same shape: best-effort, on the
617 // write path.
618 prune_old_captures(&dir);
619
620 // codex P2 on PR #2810: no process start time is collected or checked
621 // anywhere, so claiming "this window was created recently" was
622 // unsupported and misleading for a mature window that just happens to
623 // render a near-uniform frame (looks_unrendered's own doc comment
624 // already acknowledges that legitimate case). State only what was
625 // actually observed.
626 let hint = if likely_unrendered {
627 " (likely_unrendered: true — the captured frame looks solid or \
628 near-solid-color even after retrying, which can mean the window \
629 hasn't painted its first real frame yet, or that it genuinely \
630 looks like this; consider calling CaptureWindow again shortly if \
631 that's unexpected)"
632 } else {
633 ""
634 };
635 Ok(CaptureOutcome {
636 message: format!(
637 "Captured window {title:?} to {}{hint} — use Read on that path to view it yourself, or OpenMedia to show it to the user.",
638 path.display()
639 ),
640 tier: target.tier,
641 // The resolved target, not the query string — an audit reviewer needs
642 // to know WHAT was captured, which a `title_contains` substring does
643 // not tell them.
644 target: format!("pid={} title={:?}", target.pid, target.title),
645 image_sha256: sha256_file(&path),
646 })
647}
648
649/// Hex SHA-256 of a captured PNG, recorded in the audit trail so a leaked
650/// screenshot can be traced back to the call that produced it (spec §6).
651/// Best-effort — a hashing failure yields `None` and must never fail the
652/// capture, matching `audit_log_capture_window`'s own "never break the tool"
653/// discipline.
654pub(crate) fn sha256_file(path: &std::path::Path) -> Option<String> {
655 use sha2::{Digest, Sha256};
656 let bytes = std::fs::read(path).ok()?;
657 let mut hasher = Sha256::new();
658 hasher.update(&bytes);
659 Some(format!("{:x}", hasher.finalize()))
660}
661
662/// Audit trail for `CaptureWindow` — reagent P1 (PR #2709 round 4): the
663/// residual risk after rounds 2-3's scoping (a different AgentMux
664/// instance's window, which can belong to a different OS user on a shared
665/// machine) is a disclosure-across-a-human-boundary question, not a
666/// technical one a capability flag alone would fully answer — a real
667/// per-agent opt-in gate is tracked as a separate follow-up (no existing
668/// settings/enforcement mechanism to build it on today). Logging every
669/// call — who, what was requested, what happened — is the honest,
670/// shippable Phase-1 answer: best-effort (a logging failure must never
671/// break the tool itself), append-only NDJSON inside `capture_window_dir()`
672/// itself — `prune_old_captures`'s PNG-only extension filter already
673/// leaves a `.log` file in that same directory untouched, so this doesn't
674/// need (or want) its own separate directory alongside it.
675pub(crate) fn audit_log_capture_window(
676 query_desc: &str,
677 outcome: &Result<CaptureOutcome>,
678 // Resolved target/tier for the FAILURE paths — on success the outcome
679 // carries its own. See capture_window_impl's `resolved` param.
680 resolved: &Option<(CaptureTier, String)>,
681) {
682 let entry = serde_json::json!({
683 "timestamp": agentmux_common::time::now_secs_u64(),
684 "agent_id": agent_slug().unwrap_or_else(|_| "unknown".to_string()),
685 "tool": "CaptureWindow",
686 // "pid=N" or "title_contains=\"...\"" — whichever targeting mode
687 // the caller used (SPEC_AGENT_APP_API_WINDOW_CONTROL_ROBUSTNESS_2026_08_24.md
688 // added pid-based targeting as an alternative to title matching).
689 "query": query_desc,
690 // The RESOLVED target and its tier, not just the query string — a
691 // reviewer needs to know what was actually captured, which a
692 // `title_contains` substring doesn't say. Absent on failure, since
693 // nothing was resolved (spec §6).
694 "tier": outcome
695 .as_ref()
696 .ok()
697 .map(|c| c.tier.label())
698 .or_else(|| resolved.as_ref().map(|(t, _)| t.label())),
699 "target": outcome
700 .as_ref()
701 .ok()
702 .map(|c| c.target.clone())
703 .or_else(|| resolved.as_ref().map(|(_, t)| t.clone())),
704 // Traces a leaked screenshot back to the call that produced it.
705 "image_sha256": outcome.as_ref().ok().and_then(|c| c.image_sha256.clone()),
706 // Phase 1 never redacts — recorded explicitly so an unredacted T2/T4
707 // capture is distinguishable at review time once Phase 3 lands, rather
708 // than the field's absence being ambiguous between "no" and "old entry".
709 "redacted": false,
710 "outcome": match outcome {
711 Ok(c) => serde_json::json!({"result": "success", "detail": c.message}),
712 Err(e) => serde_json::json!({"result": "error", "detail": e.to_string()}),
713 },
714 });
715 append_window_audit_log_entry(&entry);
716}
717
718/// One `DiscoverWindows` entry.
719///
720/// A withheld (T3) window is LISTED but redacted, not omitted — reagentx P2 on
721/// PR #2845 caught two contradictions the omit-entirely approach created:
722/// - the `is_self` fail-safe promised an unresolvable-pid window would
723/// "still surface ... instead of hiding it", which an unconditional drop
724/// broke (an unresolvable owner resolves to T3)
725/// - this tool advertises `capturable` so a caller can "see why a window is
726/// out of reach before trying", unreachable if every listed entry is
727/// capturable by construction
728///
729/// Redacting satisfies both while keeping the disclosure closed: `title` and
730/// `exe_path` (which embeds the owning OS username) are exactly what must not
731/// cross the human boundary, and they are the only fields dropped. A pid and
732/// "someone else owns this" are already available to anything with shell
733/// access, so surfacing them costs nothing — and it makes a wholesale
734/// user-id-resolution failure diagnosable instead of silently returning an
735/// empty list.
736pub(crate) fn window_listing_entry(w: &AgentMuxWindowInfo) -> Value {
737 if w.tier.allowed() {
738 json!({
739 "pid": w.pid,
740 "title": w.title,
741 "exe_path": w.exe_path,
742 "is_self": w.is_self,
743 "is_agentmux": w.is_agentmux,
744 "tier": w.tier.label(),
745 "capturable": true,
746 })
747 } else {
748 json!({
749 "pid": w.pid,
750 "title": null,
751 "exe_path": null,
752 "is_self": w.is_self,
753 "is_agentmux": w.is_agentmux,
754 "tier": w.tier.label(),
755 "capturable": false,
756 "withheld_reason": "owned by a different OS user; title and exe_path withheld",
757 })
758 }
759}
760
761/// Audit trail for `DiscoverWindows` — reagent P1 on PR #2810: this tool
762/// discloses `exe_path` (a full filesystem path, typically embedding the OS
763/// username) for windows belonging to OTHER AgentMux instances/users on a
764/// shared machine — the exact same disclosure-across-a-human-boundary risk
765/// `audit_log_capture_window` above already exists to log, but this tool
766/// shipped with none. Same shape, same file (a single window-related audit
767/// trail is easier to review than two): best-effort, never blocks the
768/// tool's own result.
769pub(crate) fn audit_log_discover_windows(include_self: bool, include_foreign: bool, windows: &[Value]) {
770 let entry = serde_json::json!({
771 "timestamp": agentmux_common::time::now_secs_u64(),
772 "agent_id": agent_slug().unwrap_or_else(|_| "unknown".to_string()),
773 "tool": "DiscoverWindows",
774 // Both flags — reagentx P2 on PR #2845. `include_foreign` is the one
775 // that actually triggers this trail's reason for existing: it
776 // discloses non-AgentMux window titles and exe_paths. Recording only
777 // `include_self` left a reviewer scanning query strings unable to tell
778 // whether foreign disclosure was even requested, inferable only by
779 // picking through each entry's `is_agentmux` tag.
780 "query": format!("include_self={include_self} include_foreign={include_foreign}"),
781 "outcome": serde_json::json!({
782 "result": "success",
783 "window_count": windows.len(),
784 "windows": windows,
785 }),
786 });
787 append_window_audit_log_entry(&entry);
788}
789
790/// Shared append-only NDJSON writer for both window-tool audit trails above
791/// — best-effort (a logging failure must never break the tool itself),
792/// inside `capture_window_dir()` itself since `prune_old_captures`'s
793/// PNG-only extension filter already leaves a `.log` file in that same
794/// directory untouched, so this doesn't need (or want) its own directory.
795pub(crate) fn append_window_audit_log_entry(entry: &Value) {
796 let Ok(dir) = capture_window_dir() else {
797 return;
798 };
799 if std::fs::create_dir_all(&dir).is_err() {
800 return;
801 }
802 let line = format!("{entry}\n");
803 if let Ok(mut f) = std::fs::OpenOptions::new()
804 .create(true)
805 .append(true)
806 .open(dir.join("capture-window-audit.log"))
807 {
808 use std::io::Write;
809 let _ = f.write_all(line.as_bytes());
810 }
811}