Skip to main content

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}