Skip to main content

victauri_plugin/mcp/
mod.rs

1// This file is intentionally large (~3,400 lines). rmcp's `#[tool_router]`
2// macro requires every `#[tool]` method to live in a single `impl` block, so
3// splitting the handler across files would break tool registration. Parameter
4// structs are already factored into sub-modules (webview_params, window_params,
5// etc.) to keep this file focused on dispatch logic.
6
7mod authz;
8mod backend_params;
9mod compound_params;
10mod helpers;
11mod introspection_params;
12mod other_params;
13mod rest;
14mod server;
15mod verification_params;
16mod webview_params;
17mod window_params;
18
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use rmcp::handler::server::tool::ToolCallContext;
24use rmcp::handler::server::wrapper::Parameters;
25use rmcp::model::{
26    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
27    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
28    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
29    Tool, UnsubscribeRequestParams,
30};
31use rmcp::service::RequestContext;
32use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
33use tokio::sync::Mutex;
34
35use crate::VictauriState;
36use crate::bridge::WebviewBridge;
37
38use helpers::{
39    RecoveryHint, build_ghost_report, ghost_ipc_outcomes_js, ghost_ipc_projection_js,
40    ipc_catalog_projection_js, ipc_timing_projection_js, ipc_timing_stats, js_string, json_result,
41    json_truthy, merge_command_catalog, missing_param, sanitize_css_color, sanitize_injected_css,
42    tool_disabled, tool_error, tool_error_with_hint, validate_url,
43};
44
45// MCP tool *parameter* types are an internal protocol surface: they are deserialized
46// from MCP/JSON, used only inside this crate's (private) tool methods, and change every
47// release as actions/fields are added. They are deliberately NOT part of the public API
48// (`pub(crate)`, not `pub use`), so adding a tool action or field is not a breaking change
49// and `cargo semver-checks` stays meaningful. Only `server::*` (build_app*,
50// VictauriMcpHandler) is the public MCP surface consumers actually use.
51pub(crate) use backend_params::*;
52pub(crate) use compound_params::*;
53pub(crate) use introspection_params::*;
54pub(crate) use other_params::{
55    AppStateParams, DiagnosticsParams, FindElementsParams, ResolveCommandParams,
56    SemanticAssertParams, WaitCondition, WaitForParams,
57};
58pub use server::*;
59pub(crate) use verification_params::*;
60pub(crate) use webview_params::*;
61pub(crate) use window_params::*;
62
63// ── MCP Handler ──────────────────────────────────────────────────────────────
64
65/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
66/// growth of the `pending_evals` map if callbacks are never resolved.
67pub(crate) const MAX_PENDING_EVALS: usize = 100;
68
69fn chrono_now() -> String {
70    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
71}
72
73/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
74const MAX_EVAL_CODE_LEN: usize = 1_000_000;
75
76/// Maximum length of a JavaScript eval return value (5 MB).
77/// Results exceeding this are truncated to prevent memory exhaustion.
78const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
79
80/// How long the eval parse-watchdog waits for the user-code script to begin executing
81/// before reporting a likely syntax error. A parse error means the script never runs (so
82/// it never marks itself "started"); this caps that failure at ~0.75s instead of the full
83/// eval timeout, while still leaving valid-but-slow code to run to the real timeout.
84const PARSE_WATCHDOG_MS: u64 = 750;
85
86/// Default number of entries returned by IPC/network log tools when no explicit
87/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
88const DEFAULT_LOG_LIMIT: usize = 100;
89
90/// Per-field byte cap applied to each IPC/network log entry before serialization.
91/// Large request/response bodies are truncated with a marker so the aggregate
92/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
93const MAX_LOG_FIELD_BYTES: usize = 4096;
94
95/// Hard cap on entries returned by `list_app_dir` (recursive). Without it a
96/// directory with millions of files (or a wide tree at max depth) would build an
97/// unbounded result Vec and blow the eval/output cap (audit B7). When hit, the
98/// listing stops and the response is marked `truncated: true`.
99const MAX_DIR_ENTRIES: usize = 10_000;
100
101/// `db_health` performs integrity checks and table counts against app-owned
102/// databases. Bound the diagnostic so a large or adversarial DB cannot hold a
103/// blocking worker indefinitely or return an unbounded schema listing.
104#[cfg(feature = "sqlite")]
105const DB_HEALTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
106#[cfg(feature = "sqlite")]
107const DB_HEALTH_PROGRESS_OPS: i32 = 10_000;
108#[cfg(feature = "sqlite")]
109const MAX_DB_HEALTH_TABLES: usize = 1_000;
110#[cfg(feature = "sqlite")]
111const MAX_DB_HEALTH_TABLE_BYTES: usize = 1_000_000;
112#[cfg(feature = "sqlite")]
113const MAX_DB_HEALTH_CELL_BYTES: i32 = 1_048_576;
114
115const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
116const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
117const RESOURCE_URI_STATE: &str = "victauri://state";
118
119/// Map an MCP resource URI to the privacy capability that gates its
120/// tool-equivalent read. Resources are served outside the tool dispatcher, so
121/// this lets `read_resource`/`subscribe` apply the same privacy matrix (audit
122/// B1). Returns `None` for an unknown URI (handled as not-found downstream).
123fn resource_required_capability(uri: &str) -> Option<&'static str> {
124    match uri {
125        // Reading the IPC log via a resource == the `logs ipc` tool action.
126        RESOURCE_URI_IPC_LOG => Some("logs.ipc"),
127        // Window states == the `window list` action.
128        RESOURCE_URI_WINDOWS => Some("window.list"),
129        // The state summary == reading plugin info.
130        RESOURCE_URI_STATE => Some("get_plugin_info"),
131        _ => None,
132    }
133}
134
135const BRIDGE_VERSION: &str = env!("CARGO_PKG_VERSION");
136
137const SAFE_ENV_PREFIXES: &[&str] = &[
138    "HOME",
139    "USER",
140    "LANG",
141    "LC_",
142    "TERM",
143    "SHELL",
144    "DISPLAY",
145    "XDG_",
146    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
147    // app-custom namespace that can hold secrets (audit #5).
148    "TAURI_ENV_",
149    "VICTAURI_",
150    "NODE_ENV",
151    "OS",
152    "HOSTNAME",
153    "PWD",
154    "SHLVL",
155    "LOGNAME",
156];
157
158/// Substrings that mark an env var as a secret. Even when a name matches a
159/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
160/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
161/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
162const SECRET_ENV_SUBSTRINGS: &[&str] = &[
163    "TOKEN",
164    "SECRET",
165    "PASS", // PASSWORD, PASSWD, PASSPHRASE
166    "PRIVATE",
167    "CREDENTIAL",
168    "APIKEY",
169    "AUTH",
170    "_KEY",
171    "DSN", // connection strings with embedded creds
172    "PAT", // personal access token
173    "JWT",
174    "BEARER",
175    "SESSION",
176    "COOKIE",
177    "SALT",
178    "CERT",
179    "SIGN", // signing keys/material
180    "LICENSE",
181];
182
183/// Whether an env var name is safe to surface via `app_info`: it must match a
184/// known-safe prefix AND not look like a secret (audit #5).
185fn is_safe_env_key(key: &str) -> bool {
186    let upper = key.to_uppercase();
187    SAFE_ENV_PREFIXES
188        .iter()
189        .any(|prefix| upper.starts_with(prefix))
190        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
191}
192
193/// MCP tool handler that dispatches tool calls to the webview bridge and state.
194#[derive(Clone)]
195pub struct VictauriMcpHandler {
196    state: Arc<VictauriState>,
197    bridge: Arc<dyn WebviewBridge>,
198    subscriptions: Arc<Mutex<HashSet<String>>>,
199    bridge_checked: Arc<AtomicBool>,
200    /// Window keys whose previous eval timed out. Retained only to annotate the
201    /// error on the *next* eval (the bridge is probed before every eval anyway).
202    timed_out_labels: Arc<Mutex<HashSet<String>>>,
203}
204
205#[tool_router]
206impl VictauriMcpHandler {
207    // ── Standalone Tools ────────────────────────────────────────────────────
208
209    #[tool(
210        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
211        annotations(
212            read_only_hint = false,
213            destructive_hint = true,
214            idempotent_hint = false,
215            open_world_hint = false
216        )
217    )]
218    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
219        if !self.state.privacy.is_tool_enabled("eval_js") {
220            return tool_disabled("eval_js");
221        }
222        if params.code.len() > MAX_EVAL_CODE_LEN {
223            return tool_error("code exceeds maximum length (1 MB)");
224        }
225        match self
226            .eval_with_return(&params.code, params.webview_label.as_deref())
227            .await
228        {
229            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
230            Err(e) => tool_error(e),
231        }
232    }
233
234    #[tool(
235        description = "Get the DOM snapshot with stable ref handles. Default: compact accessible text (70-80%% fewer tokens). Set format=\"json\" for full tree. Returns tree + stale_refs (refs invalidated since last snapshot).",
236        annotations(
237            read_only_hint = true,
238            destructive_hint = false,
239            idempotent_hint = true,
240            open_world_hint = false
241        )
242    )]
243    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
244        let format = params.format.unwrap_or(SnapshotFormat::Compact);
245        let format_str = match format {
246            SnapshotFormat::Compact => "compact",
247            SnapshotFormat::Json => "json",
248        };
249        let code = format!(
250            "return window.__VICTAURI__?.snapshot({})",
251            js_string(format_str)
252        );
253        self.eval_bridge(&code, params.webview_label.as_deref())
254            .await
255    }
256
257    #[tool(
258        description = "Search for elements by text, role, test_id, CSS selector (via `css` or `selector` param), or accessible name without a full snapshot. Returns lightweight matches with ref handles.",
259        annotations(
260            read_only_hint = true,
261            destructive_hint = false,
262            idempotent_hint = true,
263            open_world_hint = false
264        )
265    )]
266    async fn find_elements(
267        &self,
268        Parameters(params): Parameters<FindElementsParams>,
269    ) -> CallToolResult {
270        let mut parts: Vec<String> = Vec::new();
271        if let Some(t) = &params.text {
272            parts.push(format!("text: {}", js_string(t)));
273        }
274        if let Some(r) = &params.role {
275            parts.push(format!("role: {}", js_string(r)));
276        }
277        if let Some(tid) = &params.test_id {
278            parts.push(format!("test_id: {}", js_string(tid)));
279        }
280        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
281            parts.push(format!("css: {}", js_string(c)));
282        }
283        if let Some(n) = &params.name {
284            parts.push(format!("name: {}", js_string(n)));
285        }
286        if let Some(max) = params.max_results {
287            parts.push(format!("max_results: {max}"));
288        }
289        if let Some(t) = &params.tag {
290            parts.push(format!("tag: {}", js_string(t)));
291        }
292        if let Some(p) = &params.placeholder {
293            parts.push(format!("placeholder: {}", js_string(p)));
294        }
295        if let Some(a) = &params.alt {
296            parts.push(format!("alt: {}", js_string(a)));
297        }
298        if let Some(ta) = &params.title_attr {
299            parts.push(format!("title_attr: {}", js_string(ta)));
300        }
301        if let Some(l) = &params.label {
302            parts.push(format!("label: {}", js_string(l)));
303        }
304        if let Some(true) = params.exact {
305            parts.push("exact: true".to_string());
306        }
307        if let Some(e) = params.enabled {
308            parts.push(format!("enabled: {e}"));
309        }
310        let code = format!(
311            "return window.__VICTAURI__?.findElements({{ {} }})",
312            parts.join(", ")
313        );
314        match self
315            .eval_with_return(&code, params.webview_label.as_deref())
316            .await
317        {
318            Ok(result) => {
319                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
320                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
321                {
322                    return tool_error(err);
323                }
324                CallToolResult::success(vec![Content::text(result)])
325            }
326            Err(e) => tool_error(e),
327        }
328    }
329
330    #[tool(
331        description = "Invoke a registered Tauri command via IPC, just like the frontend would. Goes through the real IPC pipeline so calls are logged and verifiable. Returns the command's result. Subject to privacy command filtering.",
332        annotations(
333            read_only_hint = false,
334            destructive_hint = true,
335            idempotent_hint = false,
336            open_world_hint = false
337        )
338    )]
339    async fn invoke_command(
340        &self,
341        Parameters(params): Parameters<InvokeCommandParams>,
342    ) -> CallToolResult {
343        if !self.state.privacy.is_invoke_allowed(&params.command) {
344            return tool_disabled("invoke_command");
345        }
346        if !self.state.privacy.is_command_allowed(&params.command) {
347            return tool_error(format!(
348                "command '{}' is blocked by privacy configuration",
349                params.command
350            ));
351        }
352
353        // ── Fault injection check ──
354        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
355            match fault {
356                crate::introspection::FaultType::Delay { delay_ms } => {
357                    tracing::info!(
358                        command = %params.command,
359                        delay_ms = delay_ms,
360                        "fault injection: delaying command"
361                    );
362                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
363                    // After delay, continue with normal execution below
364                }
365                crate::introspection::FaultType::Error { ref message } => {
366                    tracing::info!(
367                        command = %params.command,
368                        "fault injection: returning error"
369                    );
370                    return tool_error(format!(
371                        "[FAULT INJECTED] command '{}': {message}",
372                        params.command
373                    ));
374                }
375                crate::introspection::FaultType::Drop => {
376                    tracing::info!(
377                        command = %params.command,
378                        "fault injection: dropping response"
379                    );
380                    return CallToolResult::success(vec![Content::text("{}")]);
381                }
382                crate::introspection::FaultType::Corrupt => {
383                    tracing::info!(
384                        command = %params.command,
385                        "fault injection: corrupting response"
386                    );
387                    // Execute normally but mangle the response
388                    let args_json = params.args.unwrap_or(serde_json::json!({}));
389                    let args_str =
390                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
391                    let code = format!(
392                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
393                        js_string(&params.command)
394                    );
395                    if let Ok(result) = self
396                        .eval_with_return(&code, params.webview_label.as_deref())
397                        .await
398                    {
399                        let corrupted = format!(
400                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
401                            result.len()
402                        );
403                        return CallToolResult::success(vec![Content::text(corrupted)]);
404                    }
405                    return CallToolResult::success(vec![Content::text(
406                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
407                    )]);
408                }
409            }
410        }
411
412        // ── Normal execution with timing ──
413        let start = std::time::Instant::now();
414        let args_json = params.args.unwrap_or(serde_json::json!({}));
415        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
416        let code = format!(
417            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
418            js_string(&params.command)
419        );
420        let result = self
421            .eval_with_return(&code, params.webview_label.as_deref())
422            .await;
423        let elapsed = start.elapsed();
424        self.state.command_timings.record(&params.command, elapsed);
425
426        match result {
427            Ok(result) => {
428                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
429                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
430                {
431                    return tool_error(format!(
432                        "command '{}' returned error: {err}",
433                        params.command
434                    ));
435                }
436                CallToolResult::success(vec![Content::text(result)])
437            }
438            Err(e) => tool_error(format!("invoke_command failed: {e}")),
439        }
440    }
441
442    #[tool(
443        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux X11/XWayland. Pure Wayland fails safely because its available fallback would capture the full desktop rather than the requested window. A hidden (non-visible) window has no on-screen surface to capture, so requesting one returns a clear error (show it first via `window` manage_action=show) rather than a stale or wrong-window image.",
444        annotations(
445            read_only_hint = true,
446            destructive_hint = false,
447            idempotent_hint = true,
448            open_world_hint = false
449        )
450    )]
451    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
452        if !self.state.privacy.is_tool_enabled("screenshot") {
453            return tool_disabled("screenshot");
454        }
455        // Resolve the EXACT window to capture and require it to be visible BEFORE touching the
456        // OS handle. A native screenshot captures the on-screen surface; a hidden window has
457        // none, so the OS capture path (PrintWindow / CGWindowListCreateImage) silently returns
458        // stale, empty, or ANOTHER window's pixels with no error — an agent can't tell a wrong
459        // image from a right one, so a silent wrong-window image is worse than a clear failure.
460        // Two failure modes, both closed here:
461        //   1. (live-4DA, 2026-06-16) an explicitly-requested hidden window (label:"briefing")
462        //      returned the MAIN window's pixels.
463        //   2. (GPT audit, P2) `screenshot {}` with no label: the default resolver
464        //      `find_window(None)` prefers "main" UNCONDITIONALLY, so an app that hides main but
465        //      leaves a secondary window visible captured hidden main. We do NOT change
466        //      `find_window` — other callers (e.g. eval) may legitimately target a hidden window
467        //      — instead the screenshot tool resolves its own VISIBLE target and passes it
468        //      explicitly to `get_native_handle`.
469        // Note (acknowledged residual): resolve -> handle -> capture are separate calls, so a
470        // window hidden in the gap is still TOCTOU. The worst case is the same usability failure
471        // (a wrong image), not a security boundary; the guard makes the common, deterministic
472        // case correct.
473        let states = self.bridge.get_window_states(None);
474        let target_label: String = if let Some(label) = params.window_label.as_deref() {
475            // An explicit label that the enumerator reports hidden is rejected; visible or
476            // unknown labels fall through (an unknown one lets get_native_handle produce the
477            // canonical "window not found" rather than inventing an error here).
478            if states.iter().any(|s| s.label == label && !s.visible) {
479                return tool_error(format!(
480                    "window '{label}' is not visible — a native screenshot captures the \
481                     on-screen surface, and a hidden window has none (the OS capture would \
482                     return stale or another window's pixels). Show it first \
483                     (window action=manage manage_action=show label={label}), then capture."
484                ));
485            }
486            label.to_string()
487        } else {
488            // No label: prefer a visible "main", else the first visible window. NEVER silently
489            // fall back to a hidden window (the P2 bug) — error instead.
490            let pick = states
491                .iter()
492                .find(|s| s.label == "main" && s.visible)
493                .or_else(|| states.iter().find(|s| s.visible));
494            match pick {
495                Some(st) => st.label.clone(),
496                None => {
497                    return tool_error(
498                        "no visible window to capture — every window is hidden (or the UI is \
499                         not responding). Show one first (window action=manage \
500                         manage_action=show label=<label>), then capture."
501                            .to_string(),
502                    );
503                }
504            }
505        };
506        match self.bridge.get_native_handle(Some(&target_label)) {
507            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
508                Ok(png_bytes) => {
509                    use base64::Engine;
510                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
511                    CallToolResult::success(vec![Content::image(b64, "image/png")])
512                }
513                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
514            },
515            Err(e) => tool_error(format!("cannot get window handle: {e}")),
516        }
517    }
518
519    #[tool(
520        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
521        annotations(
522            read_only_hint = true,
523            destructive_hint = false,
524            idempotent_hint = true,
525            open_world_hint = false
526        )
527    )]
528    async fn verify_state(
529        &self,
530        Parameters(params): Parameters<VerifyStateParams>,
531    ) -> CallToolResult {
532        if !self.state.privacy.is_tool_enabled("eval_js") {
533            return tool_disabled("verify_state requires eval_js capability");
534        }
535        let code = format!("return ({})", params.frontend_expr);
536        let frontend_json = match self
537            .eval_with_return(&code, params.webview_label.as_deref())
538            .await
539        {
540            Ok(result) => result,
541            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
542        };
543
544        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
545            Ok(v) => v,
546            Err(e) => {
547                return tool_error(format!(
548                    "frontend expression did not return valid JSON: {e}"
549                ));
550            }
551        };
552
553        let backend_state = if let Some(state) = params.backend_state {
554            state
555        } else if let Some(ref cmd) = params.backend_command {
556            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
557            // invoke_command and the contract/replay paths — backend_command
558            // previously checked only the blocklist (audit #30 follow-up).
559            if !self.state.privacy.is_invoke_allowed(cmd)
560                || !self.state.privacy.is_command_allowed(cmd)
561            {
562                return tool_error(format!(
563                    "command '{cmd}' is blocked by privacy configuration"
564                ));
565            }
566            let args = params.backend_args.unwrap_or(serde_json::json!({}));
567            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
568            let invoke_code = format!(
569                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
570                js_string(cmd)
571            );
572            match self
573                .eval_with_return(&invoke_code, params.webview_label.as_deref())
574                .await
575            {
576                Ok(result) => match serde_json::from_str(&result) {
577                    Ok(v) => v,
578                    Err(e) => {
579                        return tool_error(format!(
580                            "backend command '{cmd}' did not return valid JSON: {e}"
581                        ));
582                    }
583                },
584                Err(e) => {
585                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
586                }
587            }
588        } else {
589            return tool_error("either backend_state or backend_command must be provided");
590        };
591
592        let result = victauri_core::verify_state(frontend_state, backend_state);
593        json_result(&result)
594    }
595
596    #[tool(
597        description = "Detect ghost commands (frontend calls with no backend handler) by IPC OUTCOME, not by guessing from Victauri's registry. Returns: `confirmed_ghosts` = commands invoked that NEVER returned success and errored 'not found' — real missing-handler bugs, HIGH confidence and independent of whether the app uses #[inspectable]; `verified_handlers` = count of commands that returned success at least once (they provably HAVE a handler, so they are never flagged — this is why a real command like `set_language` is no longer a false positive); `frontend_only` = the WEAKER candidate tier (invoked, never observed succeeding, NOT a Tauri/plugin framework builtin, and absent from the introspection registry) — confirm against the app's `tauri::generate_handler!` before filing; `excluded_builtins` = framework `plugin:*` commands (never app ghosts); `registry_only` = registered commands never invoked (informational). The `reliability` field describes only `frontend_only`; `confirmed_ghosts` is high-confidence regardless. Reads the JS-side IPC interception log (ACCUMULATES all session traffic). For a clean signal scope with `since_ms` (e.g. 5000) — invoke the suspect action, then call this with `since_ms` — or `logs {action:'clear'}` then exercise the app.",
598        annotations(
599            read_only_hint = true,
600            destructive_hint = false,
601            idempotent_hint = true,
602            open_world_hint = false
603        )
604    )]
605    async fn detect_ghost_commands(
606        &self,
607        Parameters(params): Parameters<GhostCommandParams>,
608    ) -> CallToolResult {
609        // Project a per-command OUTCOME summary in JS ({command, ok, err}, deduped). Ghost
610        // detection is outcome-based (VIC-1): a command that returned success provably has a
611        // handler and is never a ghost; one that errored "not found" is a confirmed ghost.
612        // Aggregating per command keeps this tiny even on a busy app (avoids the eval cap).
613        // When `since_ms` is set, the projection time-windows to the current test's traffic.
614        let code = ghost_ipc_outcomes_js(params.since_ms);
615        let ipc_json = match self
616            .eval_with_return(&code, params.webview_label.as_deref())
617            .await
618        {
619            Ok(r) => r,
620            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
621        };
622
623        let outcomes: Vec<crate::mcp::helpers::IpcOutcome> = match serde_json::from_str(&ipc_json) {
624            Ok(v) => v,
625            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
626        };
627
628        json_result(&build_ghost_report(&outcomes, &self.state.registry))
629    }
630
631    #[tool(
632        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
633        annotations(
634            read_only_hint = true,
635            destructive_hint = false,
636            idempotent_hint = true,
637            open_world_hint = false
638        )
639    )]
640    async fn check_ipc_integrity(
641        &self,
642        Parameters(params): Parameters<IpcIntegrityParams>,
643    ) -> CallToolResult {
644        let threshold = params.stale_threshold_ms.unwrap_or(5000);
645        let code = format!(
646            r"return (function() {{
647                var log = window.__VICTAURI__?.getIpcLog() || [];
648                var now = Date.now();
649                var threshold = {threshold};
650                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
651                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
652                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
653                var net = window.__VICTAURI__?.getNetworkLog() || [];
654                var warning = null;
655                if (log.length === 0 && net.length > 5) {{
656                    warning = 'Zero IPC calls captured but ' + net.length + ' network requests observed. IPC capture may not be working — verify the app uses Tauri IPC via fetch to ipc.localhost.';
657                }}
658                // INTEGRITY = round-trip soundness: no stuck/stale (never-returned) calls.
659                // A command that completed with an Err is a HEALTHY round-trip (it returned)
660                // — every real app exercises error paths, so counting those as 'unhealthy'
661                // would cry wolf. The error_count/errored_calls surface them for visibility,
662                // but only stale calls flip `healthy`.
663                return {{
664                    healthy: stale.length === 0,
665                    total_calls: log.length,
666                    pending_count: pending.length,
667                    stale_count: stale.length,
668                    error_count: errored.length,
669                    stale_calls: stale.slice(0, 20),
670                    errored_calls: errored.slice(0, 20),
671                    warning: warning
672                }};
673            }})()"
674        );
675        self.eval_bridge(&code, params.webview_label.as_deref())
676            .await
677    }
678
679    #[tool(
680        description = "Wait for a condition to be met. Polls at regular intervals until satisfied or timeout. Conditions: text (text appears), text_gone (text disappears), selector (CSS selector matches), selector_gone, url (URL contains value), ipc_idle (no pending IPC calls), network_idle (no pending network requests), expression (poll a JS expression in `value` until truthy or until it equals `expected` — may `await`, e.g. await a fire-and-forget command's status), event (block until the Tauri event named in `value` fires, with `since_ms` look-back). Use expression/event to await async backend work to true completion instead of guessing with a fixed sleep.",
681        annotations(
682            read_only_hint = true,
683            destructive_hint = false,
684            idempotent_hint = true,
685            open_world_hint = false
686        )
687    )]
688    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
689        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(120_000);
690        let poll = params.poll_ms.unwrap_or(200).max(20);
691
692        // The `expression` and `event` conditions are awaited server-side (they
693        // poll the eval engine and the captured event bus respectively), so a
694        // fire-and-forget backend command can be awaited to true completion.
695        match params.condition {
696            WaitCondition::Expression => {
697                return self.wait_for_expression(&params, timeout_ms, poll).await;
698            }
699            WaitCondition::Event => {
700                return self.wait_for_event(&params, timeout_ms, poll).await;
701            }
702            _ => {}
703        }
704
705        let value = params
706            .value
707            .as_ref()
708            .map_or_else(|| "null".to_string(), |v| js_string(v));
709        let code = format!(
710            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
711            js_string(params.condition.as_str())
712        );
713        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
714        match self
715            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
716            .await
717        {
718            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
719            Err(e) => tool_error(e),
720        }
721    }
722
723    /// Poll a JS expression until truthy (or `== expected`), server-side.
724    ///
725    /// Level-triggered and race-free: each poll re-evaluates the expression via
726    /// the same engine as `eval_js`, so it may `await`. Eval errors are treated
727    /// as "not yet met" (the target may not exist during startup) and the last
728    /// error is surfaced on timeout.
729    async fn wait_for_expression(
730        &self,
731        params: &WaitForParams,
732        timeout_ms: u64,
733        poll_ms: u64,
734    ) -> CallToolResult {
735        if !self.state.privacy.is_tool_enabled("eval_js") {
736            return tool_disabled("wait_for(expression) requires eval_js capability");
737        }
738        let Some(expr) = params.value.as_deref().filter(|s| !s.is_empty()) else {
739            return missing_param("value", "wait_for(expression)");
740        };
741        let code = format!("return ({expr});");
742        let start = std::time::Instant::now();
743        let deadline = start + std::time::Duration::from_millis(timeout_ms);
744        let poll = std::time::Duration::from_millis(poll_ms);
745        let mut last_value = serde_json::Value::Null;
746        let mut last_error: Option<String> = None;
747
748        loop {
749            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
750            let per_eval = remaining
751                .min(std::time::Duration::from_secs(15))
752                .max(std::time::Duration::from_secs(1));
753            match self
754                .eval_with_return_timeout(&code, params.webview_label.as_deref(), per_eval)
755                .await
756            {
757                Ok(raw) => {
758                    let val = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
759                    let met = match &params.expected {
760                        Some(expected) => &val == expected,
761                        None => json_truthy(&val),
762                    };
763                    if met {
764                        return json_result(&serde_json::json!({
765                            "ok": true,
766                            "value": val,
767                            "elapsed_ms": start.elapsed().as_millis() as u64,
768                        }));
769                    }
770                    last_value = val;
771                }
772                Err(e) => last_error = Some(e),
773            }
774
775            if std::time::Instant::now() >= deadline {
776                return json_result(&serde_json::json!({
777                    "ok": false,
778                    "error": format!("timeout after {timeout_ms}ms"),
779                    "last_value": last_value,
780                    "last_error": last_error,
781                    "elapsed_ms": start.elapsed().as_millis() as u64,
782                }));
783            }
784            tokio::time::sleep(
785                poll.min(deadline.saturating_duration_since(std::time::Instant::now())),
786            )
787            .await;
788        }
789    }
790
791    /// Block until a named Tauri event appears on the captured event bus.
792    ///
793    /// Edge-triggered: matches the most recent event whose timestamp is no older
794    /// than `since_ms` before this call began, so an event fired in the gap
795    /// between `invoke_command` and this call is still caught. Polls the
796    /// event-bus ring buffer — no webview eval involved.
797    async fn wait_for_event(
798        &self,
799        params: &WaitForParams,
800        timeout_ms: u64,
801        poll_ms: u64,
802    ) -> CallToolResult {
803        let Some(name) = params.value.as_deref().filter(|s| !s.is_empty()) else {
804            return missing_param("value", "wait_for(event)");
805        };
806        let since_ms = params.since_ms.unwrap_or(2000);
807        let start = std::time::Instant::now();
808        let baseline = chrono::Utc::now()
809            - chrono::TimeDelta::try_milliseconds(since_ms as i64).unwrap_or_default();
810        let deadline = start + std::time::Duration::from_millis(timeout_ms);
811        let poll = std::time::Duration::from_millis(poll_ms);
812
813        loop {
814            // Search newest-first for a matching event no older than the baseline.
815            let matched = self.state.event_bus.events().into_iter().rev().find(|e| {
816                e.name == name
817                    && chrono::DateTime::parse_from_rfc3339(&e.timestamp)
818                        .map_or(true, |ts| ts.with_timezone(&chrono::Utc) >= baseline)
819            });
820            if let Some(ev) = matched {
821                return json_result(&serde_json::json!({
822                    "ok": true,
823                    "event": {
824                        "name": ev.name,
825                        "payload": ev.payload,
826                        "timestamp": ev.timestamp,
827                    },
828                    "elapsed_ms": start.elapsed().as_millis() as u64,
829                }));
830            }
831            if std::time::Instant::now() >= deadline {
832                return json_result(&serde_json::json!({
833                    "ok": false,
834                    "error": format!("timeout after {timeout_ms}ms waiting for event '{name}'"),
835                    "hint": "Ensure the app emits this Tauri event and Victauri captures it: \
836                             custom events need VictauriBuilder::listen_events(&[\"…\"]); \
837                             window-lifecycle events are captured automatically.",
838                    "elapsed_ms": start.elapsed().as_millis() as u64,
839                }));
840            }
841            tokio::time::sleep(poll).await;
842        }
843    }
844
845    #[tool(
846        description = "Run a semantic assertion: evaluate a JS expression and check the result against an expected condition. Conditions: equals, not_equals, contains, greater_than, less_than, truthy, falsy, exists, type_is.",
847        annotations(
848            read_only_hint = true,
849            destructive_hint = false,
850            idempotent_hint = true,
851            open_world_hint = false
852        )
853    )]
854    async fn assert_semantic(
855        &self,
856        Parameters(params): Parameters<SemanticAssertParams>,
857    ) -> CallToolResult {
858        if !self.state.privacy.is_tool_enabled("eval_js") {
859            return tool_disabled("assert_semantic requires eval_js capability");
860        }
861        let code = format!("return ({})", params.expression);
862        let actual_json = match self
863            .eval_with_return(&code, params.webview_label.as_deref())
864            .await
865        {
866            Ok(result) => result,
867            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
868        };
869
870        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
871            Ok(v) => v,
872            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
873        };
874
875        let assertion = victauri_core::SemanticAssertion {
876            label: params.label,
877            condition: params.condition,
878            expected: params.expected,
879        };
880
881        let result = victauri_core::evaluate_assertion(actual, &assertion);
882        json_result(&result)
883    }
884
885    #[tool(
886        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
887        annotations(
888            read_only_hint = true,
889            destructive_hint = false,
890            idempotent_hint = true,
891            open_world_hint = false
892        )
893    )]
894    async fn resolve_command(
895        &self,
896        Parameters(params): Parameters<ResolveCommandParams>,
897    ) -> CallToolResult {
898        let limit = params.limit.unwrap_or(5);
899        let mut results = self.state.registry.resolve(&params.query);
900        results.truncate(limit);
901        json_result(&results)
902    }
903
904    #[tool(
905        description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via the #[inspectable] macro — apps that don't use it return names with null schemas; for those, use `introspect command_catalog` to recover real argument/result shapes from the live IPC log.",
906        annotations(
907            read_only_hint = true,
908            destructive_hint = false,
909            idempotent_hint = true,
910            open_world_hint = false
911        )
912    )]
913    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
914        let commands = match params.query {
915            Some(q) => self.state.registry.search(&q),
916            None => self.state.registry.list(),
917        };
918        json_result(&commands)
919    }
920
921    #[tool(
922        description = "Read application-defined backend state via a registered probe. With no `probe`, lists available probe names. With a `probe` name, runs it and returns its JSON snapshot. Probes give first-class, discoverable access to domain state (e.g. a scoring pipeline's version + stale-item count, a queue's depth, cache stats) that would otherwise need query_db + log-grepping. Probes run in the Rust process with no IPC round-trip. Apps register them via VictauriBuilder::probe(name, closure).",
923        annotations(
924            read_only_hint = true,
925            destructive_hint = false,
926            idempotent_hint = true,
927            open_world_hint = false
928        )
929    )]
930    async fn app_state(&self, Parameters(params): Parameters<AppStateParams>) -> CallToolResult {
931        let Some(name) = params.probe else {
932            return json_result(&serde_json::json!({ "probes": self.state.probes.names() }));
933        };
934        if let Some(value) = self.state.probes.run(&name) {
935            json_result(&value)
936        } else {
937            let available = self.state.probes.names();
938            tool_error_with_hint(
939                format!(
940                    "unknown probe '{name}'. Available probes: {}",
941                    if available.is_empty() {
942                        "(none registered — add VictauriBuilder::probe(\"name\", ...))".to_string()
943                    } else {
944                        available.join(", ")
945                    }
946                ),
947                RecoveryHint::CheckInput,
948            )
949        }
950    }
951
952    #[tool(
953        description = "Get real-time process memory statistics from the OS (working set, page file usage). On Windows returns detailed metrics; on Linux returns virtual/resident size.",
954        annotations(
955            read_only_hint = true,
956            destructive_hint = false,
957            idempotent_hint = true,
958            open_world_hint = false
959        )
960    )]
961    async fn get_memory_stats(&self) -> CallToolResult {
962        let stats = crate::memory::current_stats();
963        json_result(&stats)
964    }
965
966    #[tool(
967        description = "Inspect the Victauri plugin's own configuration: port, enabled/disabled tools, command filters, privacy settings, capacities, and version. Useful for agents to understand their capabilities before acting.",
968        annotations(
969            read_only_hint = true,
970            destructive_hint = false,
971            idempotent_hint = true,
972            open_world_hint = false
973        )
974    )]
975    async fn get_plugin_info(&self) -> CallToolResult {
976        let disabled: Vec<&str> = self
977            .state
978            .privacy
979            .disabled_tools
980            .iter()
981            .map(std::string::String::as_str)
982            .collect();
983        let blocklist: Vec<&str> = self
984            .state
985            .privacy
986            .command_blocklist
987            .iter()
988            .map(std::string::String::as_str)
989            .collect();
990        let allowlist: Option<Vec<&str>> = self
991            .state
992            .privacy
993            .command_allowlist
994            .as_ref()
995            .map(|s| s.iter().map(std::string::String::as_str).collect());
996        let all_tools = Self::tool_router().list_all();
997        let enabled_tools: Vec<&str> = all_tools
998            .iter()
999            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
1000            .map(|t| t.name.as_ref())
1001            .collect();
1002
1003        // Host-app identity: lets an agent verify on its FIRST call that it reached the
1004        // intended app (not another Victauri instance sharing the discovery port).
1005        let app_cfg = self.bridge.tauri_config();
1006        let result = serde_json::json!({
1007            "version": env!("CARGO_PKG_VERSION"),
1008            "bridge_version": BRIDGE_VERSION,
1009            "port": self.state.port.load(Ordering::Relaxed),
1010            "app": {
1011                "identifier": app_cfg.get("identifier"),
1012                "product_name": app_cfg.get("product_name"),
1013            },
1014            "tools": {
1015                "total": all_tools.len(),
1016                "enabled": enabled_tools.len(),
1017                "enabled_list": enabled_tools,
1018                "disabled_list": disabled,
1019            },
1020            "commands": {
1021                "allowlist": allowlist,
1022                "blocklist": blocklist,
1023            },
1024            "privacy": {
1025                "profile": self.state.privacy.profile.to_string(),
1026                "redaction_enabled": self.state.privacy.redaction_enabled,
1027            },
1028            "capacities": {
1029                "event_log": self.state.event_log.capacity(),
1030                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
1031            },
1032            "registered_commands": self.state.registry.count(),
1033            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
1034            "uptime_secs": self.state.started_at.elapsed().as_secs(),
1035        });
1036        json_result(&result)
1037    }
1038
1039    #[tool(
1040        description = "Run environment diagnostics: detect service workers (break IPC interception), closed shadow DOM (invisible to snapshots), iframes (bridge absent), large DOM warnings, and CSP status. Call this first when connecting to an unfamiliar app.",
1041        annotations(
1042            read_only_hint = true,
1043            destructive_hint = false,
1044            idempotent_hint = true,
1045            open_world_hint = false
1046        )
1047    )]
1048    async fn get_diagnostics(
1049        &self,
1050        Parameters(params): Parameters<DiagnosticsParams>,
1051    ) -> CallToolResult {
1052        self.eval_bridge(
1053            "return window.__VICTAURI__?.getDiagnostics()",
1054            params.webview_label.as_deref(),
1055        )
1056        .await
1057    }
1058
1059    // ── Backend Access Tools ───────────────────────────────────────────────
1060
1061    #[tool(
1062        description = "Get comprehensive app info: Tauri config (identifier, product name, version), app directory paths (data, config, log, local_data), process environment variables, and database files found in app directories. Provides direct backend context without going through the webview.",
1063        annotations(
1064            read_only_hint = true,
1065            destructive_hint = false,
1066            idempotent_hint = true,
1067            open_world_hint = false
1068        )
1069    )]
1070    async fn app_info(&self) -> CallToolResult {
1071        let config = self.bridge.tauri_config();
1072
1073        let data_dir = self.bridge.app_data_dir().ok();
1074        let config_dir = self.bridge.app_config_dir().ok();
1075        let log_dir = self.bridge.app_log_dir().ok();
1076        let local_data_dir = self.bridge.app_local_data_dir().ok();
1077
1078        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
1079            .filter(|(k, _)| is_safe_env_key(k))
1080            .collect();
1081
1082        // Enumerate every database candidate across ALL roots (configured db_search_paths
1083        // + every OS app dir), each tagged with size, whether it is a WebView/engine
1084        // internal store, and whether it is the one `query_db` would auto-select. This lets
1085        // an agent see and disambiguate the real app DB instead of guessing (audit /
1086        // red-team "wrong DB" finding — `app_info.databases` previously only walked
1087        // data_dir and returned bare relative names).
1088        #[cfg(feature = "sqlite")]
1089        let databases: Vec<serde_json::Value> = {
1090            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1091            for d in [
1092                data_dir.as_ref(),
1093                config_dir.as_ref(),
1094                log_dir.as_ref(),
1095                local_data_dir.as_ref(),
1096            ]
1097            .into_iter()
1098            .flatten()
1099            {
1100                all_dirs.push(d.clone());
1101            }
1102            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1103                all_dirs.clone()
1104            } else {
1105                self.state.db_search_paths.clone()
1106            };
1107            let selected = crate::database::select_app_database(&select_dirs).ok();
1108            crate::database::classify_databases(&all_dirs)
1109                .into_iter()
1110                .map(|c| {
1111                    serde_json::json!({
1112                        "path": c.path.to_string_lossy(),
1113                        "size_bytes": c.size_bytes,
1114                        "webview_internal": c.webview_internal,
1115                        "selected": selected.as_ref() == Some(&c.path),
1116                    })
1117                })
1118                .collect()
1119        };
1120
1121        #[cfg(not(feature = "sqlite"))]
1122        let databases: Vec<serde_json::Value> = Vec::new();
1123
1124        let result = serde_json::json!({
1125            "config": config,
1126            "paths": {
1127                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
1128                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
1129                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
1130                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
1131            },
1132            "databases": databases,
1133            "env": env_vars,
1134            "process": {
1135                "pid": std::process::id(),
1136                "arch": std::env::consts::ARCH,
1137                "os": std::env::consts::OS,
1138                "family": std::env::consts::FAMILY,
1139            },
1140        });
1141        json_result(&result)
1142    }
1143
1144    #[tool(
1145        description = "List files in the app's data, config, log, or local_data directories. Useful for discovering databases, config files, logs, and cached data on the backend — without going through the webview.",
1146        annotations(
1147            read_only_hint = true,
1148            destructive_hint = false,
1149            idempotent_hint = true,
1150            open_world_hint = false
1151        )
1152    )]
1153    async fn list_app_dir(
1154        &self,
1155        Parameters(params): Parameters<ListAppDirParams>,
1156    ) -> CallToolResult {
1157        let base = match self.resolve_app_dir(params.directory) {
1158            Ok(d) => d,
1159            Err(e) => return tool_error(e),
1160        };
1161
1162        let target = if let Some(ref sub) = params.path {
1163            // Lexical traversal guard BEFORE the existence check: `safe_within`
1164            // canonicalizes (which errors on non-existent paths), so a `..` or
1165            // absolute sub-path must be rejected as traversal up front rather
1166            // than falling through to a misleading "does not exist" result.
1167            if let Err(e) = Self::lexical_safe(std::path::Path::new(sub)) {
1168                return tool_error(e);
1169            }
1170            let resolved = base.join(sub);
1171            // A missing directory is a normal, non-error result.
1172            if !resolved.exists() {
1173                return json_result(&serde_json::json!({
1174                    "base": base.to_string_lossy(),
1175                    "path": sub,
1176                    "exists": false,
1177                    "entries": [],
1178                    "count": 0,
1179                }));
1180            }
1181            if let Err(e) = Self::safe_within(&base, &resolved) {
1182                return tool_error(e);
1183            }
1184            resolved
1185        } else {
1186            base.clone()
1187        };
1188
1189        // A missing base directory is a normal, non-error result.
1190        if !target.exists() {
1191            return json_result(&serde_json::json!({
1192                "base": base.to_string_lossy(),
1193                "path": params.path.unwrap_or_default(),
1194                "exists": false,
1195                "entries": [],
1196                "count": 0,
1197            }));
1198        }
1199
1200        let max_depth = params.max_depth.unwrap_or(1).min(5);
1201        let pattern = params.pattern.as_deref();
1202        let mut entries = Vec::new();
1203
1204        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
1205        let truncated = entries.len() >= MAX_DIR_ENTRIES;
1206
1207        json_result(&serde_json::json!({
1208            "base": base.to_string_lossy(),
1209            "path": params.path.unwrap_or_default(),
1210            "exists": true,
1211            "entries": entries,
1212            "count": entries.len(),
1213            "truncated": truncated,
1214        }))
1215    }
1216
1217    #[tool(
1218        description = "Read a file from the app's data, config, log, or local_data directory. Returns UTF-8 text by default, or base64 for binary files. Directly reads backend files without going through the webview.",
1219        annotations(
1220            read_only_hint = true,
1221            destructive_hint = false,
1222            idempotent_hint = true,
1223            open_world_hint = false
1224        )
1225    )]
1226    async fn read_app_file(
1227        &self,
1228        Parameters(params): Parameters<ReadAppFileParams>,
1229    ) -> CallToolResult {
1230        let base = match self.resolve_app_dir(params.directory) {
1231            Ok(d) => d,
1232            Err(e) => return tool_error(e),
1233        };
1234
1235        // Lexical traversal guard FIRST — before the existence check — so a
1236        // traversal attempt (`..` / absolute) is rejected as traversal rather
1237        // than leaking whether the out-of-tree target exists via "file not
1238        // found". `safe_within` (which canonicalizes) stays below as
1239        // defense-in-depth for real files.
1240        if let Err(e) = Self::lexical_safe(std::path::Path::new(&params.path)) {
1241            return tool_error(e);
1242        }
1243        let target = base.join(&params.path);
1244        if !target.exists() {
1245            return tool_error(format!("file not found: {}", params.path));
1246        }
1247        if let Err(e) = Self::safe_within(&base, &target) {
1248            return tool_error(e);
1249        }
1250        if !target.is_file() {
1251            return tool_error(format!("not a file: {}", params.path));
1252        }
1253
1254        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
1255
1256        // Open the CANONICAL validated path, and do the blocking file IO on the blocking pool.
1257        // Doing sync `std::fs` IO directly in this async fn could stall the executor thread if a
1258        // regular file were swapped (between the `safe_within` check and the open) for a FIFO or
1259        // slow device whose `read_to_end` blocks; opening the canonical path also closes the
1260        // trivial validate-lexical / open-lexical symlink-swap window.
1261        let canonical = match std::fs::canonicalize(&target) {
1262            Ok(c) => c,
1263            Err(e) => return tool_error(format!("cannot resolve path: {e}")),
1264        };
1265        #[allow(clippy::cast_possible_truncation)]
1266        let read = tokio::task::spawn_blocking(
1267            move || -> Result<(Vec<u8>, usize, Option<u64>), String> {
1268                use std::io::Read;
1269                let metadata = std::fs::metadata(&canonical).ok();
1270                let size = metadata.as_ref().map(|m| m.len() as usize);
1271                let modified = metadata.as_ref().and_then(|m| m.modified().ok()).map(|t| {
1272                    t.duration_since(std::time::SystemTime::UNIX_EPOCH)
1273                        .unwrap_or_default()
1274                        .as_secs()
1275                });
1276                // Bounded read (audit B7): pull at most max_bytes+1 instead of slurping the
1277                // whole file. The +1 detects truncation; the reported size comes from metadata.
1278                let f = std::fs::File::open(&canonical).map_err(|e| e.to_string())?;
1279                let mut buf = Vec::new();
1280                f.take(max_bytes as u64 + 1)
1281                    .read_to_end(&mut buf)
1282                    .map_err(|e| e.to_string())?;
1283                let reported = size.unwrap_or(buf.len());
1284                Ok((buf, reported, modified))
1285            },
1286        )
1287        .await;
1288        let (mut bytes, original_size, modified) = match read {
1289            Ok(Ok(v)) => v,
1290            Ok(Err(e)) => return tool_error(format!("failed to read file: {e}")),
1291            Err(e) => return tool_error(format!("file read task failed: {e}")),
1292        };
1293        let truncated = bytes.len() > max_bytes;
1294        if truncated {
1295            bytes.truncate(max_bytes);
1296        }
1297
1298        let file_info = serde_json::json!({
1299            "path": params.path,
1300            "size": original_size,
1301            "truncated": truncated,
1302            "modified": modified,
1303        });
1304
1305        if params.binary == Some(true) {
1306            use base64::Engine;
1307            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1308            json_result(&serde_json::json!({
1309                "file": file_info,
1310                "encoding": "base64",
1311                "content": b64,
1312            }))
1313        } else {
1314            match String::from_utf8(bytes) {
1315                Ok(text) => json_result(&serde_json::json!({
1316                    "file": file_info,
1317                    "encoding": "utf-8",
1318                    "content": text,
1319                })),
1320                Err(e) => {
1321                    use base64::Engine;
1322                    let bytes = e.into_bytes();
1323                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1324                    json_result(&serde_json::json!({
1325                        "file": file_info,
1326                        "encoding": "base64",
1327                        "note": "file is not valid UTF-8, returning base64",
1328                        "content": b64,
1329                    }))
1330                }
1331            }
1332        }
1333    }
1334
1335    #[tool(
1336        description = "Execute a bounded, read-only SQL query against a SQLite database in the app's data directory. The SQL goes in the `query` field (alias: `sql`). Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. CPU time, cell size, row count, and returned bytes are capped. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
1337        annotations(
1338            read_only_hint = true,
1339            destructive_hint = false,
1340            idempotent_hint = true,
1341            open_world_hint = false
1342        )
1343    )]
1344    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1345        // query_db is ALWAYS registered as a tool so the rmcp `#[tool_router]` macro
1346        // compiles with `default-features = false` (a consumer that drops the heavy
1347        // rusqlite C dependency). The actual SQLite implementation only exists with the
1348        // `sqlite` feature; without it, return a clear, actionable error.
1349        #[cfg(feature = "sqlite")]
1350        {
1351            self.query_db_impl(params).await
1352        }
1353        #[cfg(not(feature = "sqlite"))]
1354        {
1355            let _ = params;
1356            tool_error(
1357                "query_db is unavailable: this build was compiled without the 'sqlite' \
1358                 feature (default-features = false). Re-enable the 'sqlite' feature to use it.",
1359            )
1360        }
1361    }
1362
1363    /// Real `query_db` implementation — compiled only with the `sqlite` feature.
1364    #[cfg(feature = "sqlite")]
1365    async fn query_db_impl(&self, params: QueryDbParams) -> CallToolResult {
1366        let data_dir = match self.bridge.app_data_dir() {
1367            Ok(d) => d,
1368            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1369        };
1370
1371        let app_dirs: Vec<std::path::PathBuf> = [
1372            self.bridge.app_data_dir(),
1373            self.bridge.app_config_dir(),
1374            self.bridge.app_local_data_dir(),
1375            self.bridge.app_log_dir(),
1376        ]
1377        .into_iter()
1378        .filter_map(Result::ok)
1379        .collect::<std::collections::HashSet<_>>()
1380        .into_iter()
1381        .collect();
1382        // Explicitly-configured roots (VictauriBuilder::db_search_paths) take
1383        // precedence over OS app directories for auto-discovery, so a configured
1384        // application DB wins over incidental ones (e.g. WebView internals).
1385        let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1386        search_dirs.extend(app_dirs);
1387
1388        let db_path = if let Some(ref requested_path) = params.path {
1389            match Self::resolve_existing_db_path(&search_dirs, requested_path) {
1390                Ok(path) => path,
1391                Err(e) => return tool_error(e),
1392            }
1393        } else {
1394            // Auto-select the application DB. When db_search_paths is configured it is
1395            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1396            // so a configured-but-empty root yields a clear error instead of silently
1397            // querying the wrong database. WebView/browser-engine internal stores are
1398            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1399            // DB" finding).
1400            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1401                search_dirs.clone()
1402            } else {
1403                self.state.db_search_paths.clone()
1404            };
1405            match crate::database::select_app_database(&select_dirs) {
1406                Ok(p) => p,
1407                Err(e) => return tool_error(e),
1408            }
1409        };
1410
1411        let db_display = db_path
1412            .strip_prefix(&data_dir)
1413            .unwrap_or(&db_path)
1414            .to_string_lossy()
1415            .into_owned();
1416        let bind_params = params.params.unwrap_or_default();
1417        let query = params.query;
1418        let max_rows = params.max_rows;
1419
1420        match tokio::task::spawn_blocking(move || {
1421            crate::database::query(&db_path, &query, &bind_params, max_rows)
1422        })
1423        .await
1424        {
1425            Ok(Ok(mut result)) => {
1426                if let Some(obj) = result.as_object_mut() {
1427                    obj.insert("database".to_string(), serde_json::json!(db_display));
1428                }
1429                json_result(&result)
1430            }
1431            Ok(Err(e)) => tool_error(e),
1432            Err(e) => tool_error(format!("database query task failed: {e}")),
1433        }
1434    }
1435
1436    // ── Compound Tools ──────────────────────────────────────────────────────
1437
1438    #[tool(
1439        description = "DOM element interactions. Actions: click, double_click, hover, focus, scroll_into_view, select_option. Requires ref_id from a dom_snapshot for most actions.",
1440        annotations(
1441            read_only_hint = false,
1442            destructive_hint = false,
1443            idempotent_hint = false,
1444            open_world_hint = false
1445        )
1446    )]
1447    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1448        if !self.state.privacy.is_tool_enabled("interact") {
1449            return tool_disabled("interact");
1450        }
1451        match params.action {
1452            InteractAction::Click => {
1453                if !self.state.privacy.is_tool_enabled("interact.click") {
1454                    return tool_disabled("interact.click");
1455                }
1456                let Some(ref_id) = &params.ref_id else {
1457                    return missing_param("ref_id", "click");
1458                };
1459                if params.trusted.unwrap_or(false) {
1460                    // Resolve the element's viewport-center coords, run the
1461                    // actionability check, then deliver a real OS click.
1462                    let probe = format!(
1463                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1464                         if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1465                         var __b=__e.getBoundingClientRect(); \
1466                         return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1467                        js_string(ref_id)
1468                    );
1469                    let raw = match self
1470                        .eval_with_return(&probe, params.webview_label.as_deref())
1471                        .await
1472                    {
1473                        Ok(r) => r,
1474                        Err(e) => return tool_error(e),
1475                    };
1476                    let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1477                        return tool_error_with_hint(
1478                            format!("ref not found: {ref_id}"),
1479                            RecoveryHint::CheckInput,
1480                        );
1481                    };
1482                    let (Some(x), Some(y)) = (
1483                        point.get("x").and_then(serde_json::Value::as_f64),
1484                        point.get("y").and_then(serde_json::Value::as_f64),
1485                    ) else {
1486                        return tool_error_with_hint(
1487                            format!("ref not found: {ref_id}"),
1488                            RecoveryHint::CheckInput,
1489                        );
1490                    };
1491                    let bridge = self.bridge.clone();
1492                    let label = params.webview_label.clone();
1493                    let native = tokio::task::spawn_blocking(move || {
1494                        bridge.native_click(label.as_deref(), x, y)
1495                    })
1496                    .await
1497                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1498                    return match native {
1499                        Ok(()) => json_result(
1500                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1501                        ),
1502                        Err(e) => tool_error(e),
1503                    };
1504                }
1505                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1506                self.eval_bridge(&code, params.webview_label.as_deref())
1507                    .await
1508            }
1509            InteractAction::DoubleClick => {
1510                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1511                    return tool_disabled("interact.double_click");
1512                }
1513                let Some(ref_id) = &params.ref_id else {
1514                    return missing_param("ref_id", "double_click");
1515                };
1516                let code = format!(
1517                    "return window.__VICTAURI__?.doubleClick({})",
1518                    js_string(ref_id)
1519                );
1520                self.eval_bridge(&code, params.webview_label.as_deref())
1521                    .await
1522            }
1523            InteractAction::Hover => {
1524                if !self.state.privacy.is_tool_enabled("interact.hover") {
1525                    return tool_disabled("interact.hover");
1526                }
1527                let Some(ref_id) = &params.ref_id else {
1528                    return missing_param("ref_id", "hover");
1529                };
1530                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1531                self.eval_bridge(&code, params.webview_label.as_deref())
1532                    .await
1533            }
1534            InteractAction::Focus => {
1535                if !self.state.privacy.is_tool_enabled("interact.focus") {
1536                    return tool_disabled("interact.focus");
1537                }
1538                let Some(ref_id) = &params.ref_id else {
1539                    return missing_param("ref_id", "focus");
1540                };
1541                let code = format!(
1542                    "return window.__VICTAURI__?.focusElement({})",
1543                    js_string(ref_id)
1544                );
1545                self.eval_bridge(&code, params.webview_label.as_deref())
1546                    .await
1547            }
1548            InteractAction::ScrollIntoView => {
1549                if !self
1550                    .state
1551                    .privacy
1552                    .is_tool_enabled("interact.scroll_into_view")
1553                {
1554                    return tool_disabled("interact.scroll_into_view");
1555                }
1556                let ref_arg = params
1557                    .ref_id
1558                    .as_ref()
1559                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1560                let x = params.x.unwrap_or(0.0);
1561                let y = params.y.unwrap_or(0.0);
1562                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1563                self.eval_bridge(&code, params.webview_label.as_deref())
1564                    .await
1565            }
1566            InteractAction::SelectOption => {
1567                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1568                    return tool_disabled("interact.select_option");
1569                }
1570                let Some(ref_id) = &params.ref_id else {
1571                    return missing_param("ref_id", "select_option");
1572                };
1573                let values_vec;
1574                let values: &[String] = match (&params.values, &params.value) {
1575                    (Some(v), _) => v,
1576                    (None, Some(v)) => {
1577                        values_vec = vec![v.clone()];
1578                        &values_vec
1579                    }
1580                    (None, None) => &[],
1581                };
1582                let values_json =
1583                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1584                let code = format!(
1585                    "return window.__VICTAURI__?.selectOption({}, {})",
1586                    js_string(ref_id),
1587                    values_json
1588                );
1589                self.eval_bridge(&code, params.webview_label.as_deref())
1590                    .await
1591            }
1592        }
1593    }
1594
1595    #[tool(
1596        description = "Text and keyboard input. Actions: fill (set input value), type_text (character-by-character typing), press_key (trigger a keyboard key). Subject to privacy controls.",
1597        annotations(
1598            read_only_hint = false,
1599            destructive_hint = false,
1600            idempotent_hint = false,
1601            open_world_hint = false
1602        )
1603    )]
1604    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1605        match params.action {
1606            InputAction::Fill => {
1607                if !self.state.privacy.is_tool_enabled("fill") {
1608                    return tool_disabled("fill");
1609                }
1610                let Some(ref_id) = &params.ref_id else {
1611                    return missing_param("ref_id", "fill");
1612                };
1613                let Some(value) = &params.value else {
1614                    return missing_param("value", "fill");
1615                };
1616                let code = format!(
1617                    "return window.__VICTAURI__?.fill({}, {})",
1618                    js_string(ref_id),
1619                    js_string(value)
1620                );
1621                self.eval_bridge(&code, params.webview_label.as_deref())
1622                    .await
1623            }
1624            InputAction::TypeText => {
1625                if !self.state.privacy.is_tool_enabled("type_text") {
1626                    return tool_disabled("type_text");
1627                }
1628                let Some(ref_id) = &params.ref_id else {
1629                    return missing_param("ref_id", "type_text");
1630                };
1631                let Some(text) = &params.text else {
1632                    return missing_param("text", "type_text");
1633                };
1634                if params.trusted.unwrap_or(false) {
1635                    // Focus the element via JS, then deliver real OS keystrokes
1636                    // (isTrusted: true) for handlers that reject synthetic events.
1637                    let focus = format!(
1638                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1639                        js_string(ref_id)
1640                    );
1641                    let focused = self
1642                        .eval_with_return(&focus, params.webview_label.as_deref())
1643                        .await
1644                        .unwrap_or_default();
1645                    if focused != "true" {
1646                        return tool_error_with_hint(
1647                            format!("ref not found or not focusable: {ref_id}"),
1648                            RecoveryHint::CheckInput,
1649                        );
1650                    }
1651                    let bridge = self.bridge.clone();
1652                    let label = params.webview_label.clone();
1653                    let text = text.to_string();
1654                    let native = tokio::task::spawn_blocking(move || {
1655                        bridge.native_type_text(label.as_deref(), &text)
1656                    })
1657                    .await
1658                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1659                    return match native {
1660                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1661                        Err(e) => tool_error(e),
1662                    };
1663                }
1664                let code = format!(
1665                    "return window.__VICTAURI__?.type({}, {})",
1666                    js_string(ref_id),
1667                    js_string(text)
1668                );
1669                self.eval_bridge(&code, params.webview_label.as_deref())
1670                    .await
1671            }
1672            InputAction::PressKey => {
1673                if !self.state.privacy.is_tool_enabled("input.press_key") {
1674                    return tool_disabled("input.press_key");
1675                }
1676                let Some(key) = &params.key else {
1677                    return missing_param("key", "press_key");
1678                };
1679                if params.trusted.unwrap_or(false) {
1680                    // Optionally focus a target element, then send a real OS key.
1681                    if let Some(ref_id) = &params.ref_id {
1682                        let focus = format!(
1683                            "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1684                            js_string(ref_id)
1685                        );
1686                        let _ = self
1687                            .eval_with_return(&focus, params.webview_label.as_deref())
1688                            .await;
1689                    }
1690                    let bridge = self.bridge.clone();
1691                    let label = params.webview_label.clone();
1692                    let key = key.to_string();
1693                    let native = tokio::task::spawn_blocking(move || {
1694                        bridge.native_key(label.as_deref(), &key)
1695                    })
1696                    .await
1697                    .unwrap_or_else(|e| Err(format!("native input task failed: {e}")));
1698                    return match native {
1699                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1700                        Err(e) => tool_error(e),
1701                    };
1702                }
1703                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1704                self.eval_bridge(&code, params.webview_label.as_deref())
1705                    .await
1706            }
1707        }
1708    }
1709
1710    #[tool(
1711        description = "Window management. Actions: get_state (window positions/sizes/visibility), list (all window labels), manage (minimize/maximize/close/focus/show/hide/fullscreen/always_on_top), resize, move_to, set_title, introspectability (probe every window and report which Victauri can actually see — a visible window that comes back introspectable:false is almost always missing the \"victauri:default\" capability; run this FIRST when eval_js/dom_snapshot/animation return nothing for a multi-window app).",
1712        annotations(
1713            read_only_hint = false,
1714            destructive_hint = false,
1715            idempotent_hint = true,
1716            open_world_hint = false
1717        )
1718    )]
1719    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1720        match params.action {
1721            WindowAction::GetState => {
1722                let states = self.bridge.get_window_states(params.label.as_deref());
1723                // A specific label that matches no window is an error, not an
1724                // empty array (which reads as "success, no state").
1725                if states.is_empty()
1726                    && let Some(label) = params.label.as_deref()
1727                {
1728                    return tool_error(format!(
1729                        "window not found: '{label}' (use window.list to see available labels)"
1730                    ));
1731                }
1732                json_result(&states)
1733            }
1734            WindowAction::List => {
1735                let labels = self.bridge.list_window_labels();
1736                json_result(&labels)
1737            }
1738            WindowAction::Introspectability => self.window_introspectability().await,
1739            WindowAction::Manage => {
1740                if !self.state.privacy.is_tool_enabled("window.manage") {
1741                    return tool_disabled("window.manage");
1742                }
1743                let Some(manage_action) = &params.manage_action else {
1744                    return missing_param("manage_action", "manage");
1745                };
1746                match self
1747                    .bridge
1748                    .manage_window(params.label.as_deref(), manage_action.as_str())
1749                {
1750                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1751                    Err(e) => tool_error(e),
1752                }
1753            }
1754            WindowAction::Resize => {
1755                if !self.state.privacy.is_tool_enabled("window.resize") {
1756                    return tool_disabled("window.resize");
1757                }
1758                let Some(width) = params.width else {
1759                    return missing_param("width", "resize");
1760                };
1761                let Some(height) = params.height else {
1762                    return missing_param("height", "resize");
1763                };
1764                if width == 0 || height == 0 {
1765                    return tool_error_with_hint(
1766                        format!(
1767                            "invalid window size {width}x{height}: width and height must be > 0"
1768                        ),
1769                        RecoveryHint::CheckInput,
1770                    );
1771                }
1772                match self
1773                    .bridge
1774                    .resize_window(params.label.as_deref(), width, height)
1775                {
1776                    Ok(()) => {
1777                        let result =
1778                            serde_json::json!({"ok": true, "width": width, "height": height});
1779                        CallToolResult::success(vec![Content::text(result.to_string())])
1780                    }
1781                    Err(e) => tool_error(e),
1782                }
1783            }
1784            WindowAction::MoveTo => {
1785                if !self.state.privacy.is_tool_enabled("window.move_to") {
1786                    return tool_disabled("window.move_to");
1787                }
1788                let Some(x) = params.x else {
1789                    return missing_param("x", "move_to");
1790                };
1791                let Some(y) = params.y else {
1792                    return missing_param("y", "move_to");
1793                };
1794                match self.bridge.move_window(params.label.as_deref(), x, y) {
1795                    Ok(()) => {
1796                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1797                        CallToolResult::success(vec![Content::text(result.to_string())])
1798                    }
1799                    Err(e) => tool_error(e),
1800                }
1801            }
1802            WindowAction::SetTitle => {
1803                if !self.state.privacy.is_tool_enabled("window.set_title") {
1804                    return tool_disabled("window.set_title");
1805                }
1806                let Some(title) = &params.title else {
1807                    return missing_param("title", "set_title");
1808                };
1809                match self.bridge.set_window_title(params.label.as_deref(), title) {
1810                    Ok(()) => {
1811                        let result = serde_json::json!({"ok": true, "title": title});
1812                        CallToolResult::success(vec![Content::text(result.to_string())])
1813                    }
1814                    Err(e) => tool_error(e),
1815                }
1816            }
1817        }
1818    }
1819
1820    #[tool(
1821        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1822        annotations(
1823            read_only_hint = false,
1824            destructive_hint = true,
1825            idempotent_hint = false,
1826            open_world_hint = false
1827        )
1828    )]
1829    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1830        match params.action {
1831            StorageAction::Get => {
1832                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1833                    StorageType::Session => "getSessionStorage",
1834                    StorageType::Local => "getLocalStorage",
1835                };
1836                let key_arg = params
1837                    .key
1838                    .as_ref()
1839                    .map(|k| js_string(k))
1840                    .unwrap_or_default();
1841                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1842                self.eval_bridge(&code, params.webview_label.as_deref())
1843                    .await
1844            }
1845            StorageAction::Set => {
1846                if !self.state.privacy.is_tool_enabled("set_storage") {
1847                    return tool_disabled("set_storage");
1848                }
1849                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1850                    StorageType::Session => "setSessionStorage",
1851                    StorageType::Local => "setLocalStorage",
1852                };
1853                let Some(key) = &params.key else {
1854                    return missing_param("key", "set");
1855                };
1856                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
1857                // via storage.set (audit #33).
1858                if !self.state.privacy.is_storage_key_allowed(key) {
1859                    return tool_error(format!(
1860                        "storage key '{key}' is protected by privacy configuration"
1861                    ));
1862                }
1863                let value = params
1864                    .value
1865                    .as_ref()
1866                    .cloned()
1867                    .unwrap_or(serde_json::Value::Null);
1868                let value_json =
1869                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1870                let code = format!(
1871                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1872                    js_string(key)
1873                );
1874                self.eval_bridge(&code, params.webview_label.as_deref())
1875                    .await
1876            }
1877            StorageAction::Delete => {
1878                if !self.state.privacy.is_tool_enabled("delete_storage") {
1879                    return tool_disabled("delete_storage");
1880                }
1881                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1882                    StorageType::Session => "deleteSessionStorage",
1883                    StorageType::Local => "deleteLocalStorage",
1884                };
1885                let Some(key) = &params.key else {
1886                    return missing_param("key", "delete");
1887                };
1888                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1889                self.eval_bridge(&code, params.webview_label.as_deref())
1890                    .await
1891            }
1892            StorageAction::GetCookies => {
1893                self.eval_bridge(
1894                    "return window.__VICTAURI__?.getCookies()",
1895                    params.webview_label.as_deref(),
1896                )
1897                .await
1898            }
1899        }
1900    }
1901
1902    #[tool(
1903        description = "Navigation and dialog control. Actions: go_to (navigate to URL), go_back (browser back), get_history (navigation log), set_dialog_response (auto-respond to alert/confirm/prompt), get_dialog_log (captured dialog events). Subject to privacy controls for go_to and set_dialog_response.",
1904        annotations(
1905            read_only_hint = false,
1906            destructive_hint = false,
1907            idempotent_hint = false,
1908            open_world_hint = false
1909        )
1910    )]
1911    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1912        match params.action {
1913            NavigateAction::GoTo => {
1914                if !self.state.privacy.is_tool_enabled("navigate") {
1915                    return tool_disabled("navigate");
1916                }
1917                let Some(url) = &params.url else {
1918                    return missing_param("url", "go_to");
1919                };
1920                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1921                    return tool_error(e);
1922                }
1923                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1924                self.eval_bridge(&code, params.webview_label.as_deref())
1925                    .await
1926            }
1927            NavigateAction::GoBack => {
1928                self.eval_bridge(
1929                    "return window.__VICTAURI__?.navigateBack()",
1930                    params.webview_label.as_deref(),
1931                )
1932                .await
1933            }
1934            NavigateAction::GetHistory => {
1935                self.eval_bridge(
1936                    "return window.__VICTAURI__?.getNavigationLog()",
1937                    params.webview_label.as_deref(),
1938                )
1939                .await
1940            }
1941            NavigateAction::SetDialogResponse => {
1942                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1943                    return tool_disabled("set_dialog_response");
1944                }
1945                let Some(dialog_type) = params.dialog_type else {
1946                    return missing_param("dialog_type", "set_dialog_response");
1947                };
1948                let Some(dialog_action) = params.dialog_action else {
1949                    return missing_param("dialog_action", "set_dialog_response");
1950                };
1951                let text_arg = params
1952                    .text
1953                    .as_ref()
1954                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1955                let code = format!(
1956                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1957                    js_string(dialog_type.as_str()),
1958                    js_string(dialog_action.as_str())
1959                );
1960                self.eval_bridge(&code, params.webview_label.as_deref())
1961                    .await
1962            }
1963            NavigateAction::GetDialogLog => {
1964                self.eval_bridge(
1965                    "return window.__VICTAURI__?.getDialogLog()",
1966                    params.webview_label.as_deref(),
1967                )
1968                .await
1969            }
1970        }
1971    }
1972
1973    #[tool(
1974        description = "Time-travel recording. Actions: start (begin recording), stop (end and return session), checkpoint (save state snapshot), list_checkpoints, get_events (since index), events_between (two checkpoints), get_replay (IPC replay sequence), export (session as JSON), import (load session from JSON), replay (re-execute recorded IPC commands and compare responses), flush (immediately drain pending events into recording without waiting for the 1-second poll).",
1975        annotations(
1976            read_only_hint = false,
1977            destructive_hint = false,
1978            idempotent_hint = false,
1979            open_world_hint = false
1980        )
1981    )]
1982    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1983        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1984        if !self.state.privacy.is_tool_enabled("recording") {
1985            return tool_disabled("recording");
1986        }
1987        match params.action {
1988            RecordingAction::Start => {
1989                let session_id = params
1990                    .session_id
1991                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1992                match self.state.recorder.start(session_id.clone()) {
1993                    Ok(()) => {
1994                        let result = serde_json::json!({
1995                            "started": true,
1996                            "session_id": session_id,
1997                        });
1998                        CallToolResult::success(vec![Content::text(result.to_string())])
1999                    }
2000                    Err(e) => tool_error(e.to_string()),
2001                }
2002            }
2003            RecordingAction::Stop => match self.state.recorder.stop() {
2004                Some(session) => json_result(&session),
2005                None => tool_error("no recording is active"),
2006            },
2007            RecordingAction::Checkpoint => {
2008                // checkpoint_id is optional — auto-generate a short id when the
2009                // caller just wants a positional marker. The id is echoed back in
2010                // the response so it can be referenced later
2011                // (events_between_checkpoints / replay).
2012                let id = params
2013                    .checkpoint_id
2014                    .unwrap_or_else(|| format!("cp-{}", uuid::Uuid::new_v4()));
2015                let state = params.state.unwrap_or(serde_json::Value::Null);
2016                match self
2017                    .state
2018                    .recorder
2019                    .checkpoint(id.clone(), params.checkpoint_label, state)
2020                {
2021                    Ok(()) => {
2022                        let result = serde_json::json!({
2023                            "created": true,
2024                            "checkpoint_id": id,
2025                            "event_index": self.state.recorder.event_count(),
2026                        });
2027                        CallToolResult::success(vec![Content::text(result.to_string())])
2028                    }
2029                    Err(e) => tool_error(e.to_string()),
2030                }
2031            }
2032            RecordingAction::ListCheckpoints => {
2033                let checkpoints = self.state.recorder.get_checkpoints();
2034                json_result(&checkpoints)
2035            }
2036            RecordingAction::GetEvents => {
2037                let events = self
2038                    .state
2039                    .recorder
2040                    .events_since(params.since_index.unwrap_or(0));
2041                json_result(&events)
2042            }
2043            RecordingAction::EventsBetween => {
2044                let Some(from) = &params.from else {
2045                    return missing_param("from", "events_between");
2046                };
2047                let Some(to) = &params.to else {
2048                    return missing_param("to", "events_between");
2049                };
2050                match self.state.recorder.events_between_checkpoints(from, to) {
2051                    Ok(events) => json_result(&events),
2052                    Err(e) => tool_error(e.to_string()),
2053                }
2054            }
2055            RecordingAction::GetReplay => {
2056                let calls = self.state.recorder.ipc_replay_sequence();
2057                json_result(&calls)
2058            }
2059            RecordingAction::Export => match self.state.recorder.export() {
2060                Some(s) => {
2061                    let json = serde_json::to_string_pretty(&s)
2062                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
2063                    CallToolResult::success(vec![Content::text(json)])
2064                }
2065                None => tool_error("no recording is active — start one first"),
2066            },
2067            RecordingAction::Import => {
2068                let Some(session_json) = &params.session_json else {
2069                    return missing_param("session_json", "import");
2070                };
2071                if session_json.len() > MAX_SESSION_JSON {
2072                    return tool_error("session JSON exceeds maximum size (10 MB)");
2073                }
2074                let session: victauri_core::RecordedSession =
2075                    match serde_json::from_str(session_json) {
2076                        Ok(s) => s,
2077                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
2078                    };
2079
2080                let result = serde_json::json!({
2081                    "imported": true,
2082                    "session_id": session.id,
2083                    "event_count": session.events.len(),
2084                    "checkpoint_count": session.checkpoints.len(),
2085                    "started_at": session.started_at.to_rfc3339(),
2086                });
2087                self.state.recorder.import(session);
2088                CallToolResult::success(vec![Content::text(result.to_string())])
2089            }
2090            RecordingAction::Flush => {
2091                if !self.state.recorder.is_recording() {
2092                    return tool_error("no active recording — start a recording first");
2093                }
2094                let code = "return window.__VICTAURI__?.getEventStream(0)";
2095                match self
2096                    .eval_with_return(code, params.webview_label.as_deref())
2097                    .await
2098                {
2099                    Ok(result_str) => {
2100                        let events: Vec<serde_json::Value> =
2101                            serde_json::from_str(&result_str).unwrap_or_default();
2102                        let mut count = 0u64;
2103                        for ev in &events {
2104                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
2105                                self.state.event_log.push(app_event.clone());
2106                                self.state.recorder.record_event(app_event);
2107                                count += 1;
2108                            }
2109                        }
2110                        json_result(&serde_json::json!({
2111                            "flushed": true,
2112                            "events_captured": count,
2113                        }))
2114                    }
2115                    Err(e) => tool_error(format!("flush failed: {e}")),
2116                }
2117            }
2118            RecordingAction::Replay => {
2119                let calls = self.state.recorder.ipc_replay_sequence();
2120                if calls.is_empty() {
2121                    return tool_error("no IPC calls recorded — record a session first");
2122                }
2123                let mut replay_results = Vec::new();
2124                for call in &calls {
2125                    // Enforce the same command allow/blocklist as invoke_command
2126                    // (audit #30/#31): a recorded/imported session must not be able to
2127                    // invoke a command an operator blocked.
2128                    if !self.state.privacy.is_invoke_allowed(&call.command)
2129                        || !self.state.privacy.is_command_allowed(&call.command)
2130                    {
2131                        replay_results.push(serde_json::json!({
2132                            "command": call.command,
2133                            "status": "blocked",
2134                            "error": "blocked by privacy configuration",
2135                        }));
2136                        continue;
2137                    }
2138                    let code = format!(
2139                        "return window.__TAURI_INTERNALS__.invoke({})",
2140                        js_string(&call.command)
2141                    );
2142                    let outcome = match self
2143                        .eval_with_return(&code, params.webview_label.as_deref())
2144                        .await
2145                    {
2146                        Ok(result_str) => {
2147                            let value: serde_json::Value = serde_json::from_str(&result_str)
2148                                .unwrap_or(serde_json::Value::String(result_str));
2149                            let shape = crate::introspection::JsonShape::from_value(&value);
2150                            serde_json::json!({
2151                                "command": call.command,
2152                                "status": "ok",
2153                                "response_type": shape.type_name(),
2154                            })
2155                        }
2156                        Err(e) => {
2157                            serde_json::json!({
2158                                "command": call.command,
2159                                "status": "error",
2160                                "error": e,
2161                            })
2162                        }
2163                    };
2164                    replay_results.push(outcome);
2165                }
2166                let passed = replay_results
2167                    .iter()
2168                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
2169                    .count();
2170                let result = serde_json::json!({
2171                    "replayed": replay_results.len(),
2172                    "passed": passed,
2173                    "failed": replay_results.len() - passed,
2174                    "results": replay_results,
2175                });
2176                json_result(&result)
2177            }
2178        }
2179    }
2180
2181    #[tool(
2182        description = "CSS and visual inspection. Actions: get_styles (computed CSS for element), get_bounding_boxes (layout rects), highlight (debug overlay), clear_highlights, audit_accessibility (a11y audit), get_performance (timing/heap/DOM metrics).",
2183        annotations(
2184            read_only_hint = true,
2185            destructive_hint = false,
2186            idempotent_hint = true,
2187            open_world_hint = false
2188        )
2189    )]
2190    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
2191        match params.action {
2192            InspectAction::GetStyles => {
2193                let Some(ref_id) = &params.ref_id else {
2194                    return missing_param("ref_id", "get_styles");
2195                };
2196                let props_arg = match &params.properties {
2197                    Some(props) => {
2198                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
2199                        format!("[{}]", arr.join(","))
2200                    }
2201                    None => "null".to_string(),
2202                };
2203                let code = format!(
2204                    "return window.__VICTAURI__?.getStyles({}, {})",
2205                    js_string(ref_id),
2206                    props_arg
2207                );
2208                self.eval_bridge(&code, params.webview_label.as_deref())
2209                    .await
2210            }
2211            InspectAction::GetBoundingBoxes => {
2212                let Some(ref_ids) = &params.ref_ids else {
2213                    return missing_param("ref_ids", "get_bounding_boxes");
2214                };
2215                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
2216                let code = format!(
2217                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
2218                    refs.join(",")
2219                );
2220                self.eval_bridge(&code, params.webview_label.as_deref())
2221                    .await
2222            }
2223            InspectAction::Highlight => {
2224                // highlight injects a debug overlay node into the page — a DOM
2225                // mutation — so it is gated separately and excluded from the
2226                // read-only Observe profile (red-team P1).
2227                if !self.state.privacy.is_tool_enabled("inspect.highlight") {
2228                    return tool_disabled("inspect.highlight");
2229                }
2230                let Some(ref_id) = &params.ref_id else {
2231                    return missing_param("ref_id", "highlight");
2232                };
2233                let color_arg = match &params.color {
2234                    Some(c) => match sanitize_css_color(c) {
2235                        Ok(safe) => format!("\"{safe}\""),
2236                        Err(e) => return tool_error(e),
2237                    },
2238                    None => "null".to_string(),
2239                };
2240                let label_arg = match &params.label {
2241                    Some(l) => js_string(l),
2242                    None => "null".to_string(),
2243                };
2244                let code = format!(
2245                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
2246                    js_string(ref_id),
2247                    color_arg,
2248                    label_arg
2249                );
2250                self.eval_bridge(&code, params.webview_label.as_deref())
2251                    .await
2252            }
2253            InspectAction::ClearHighlights => {
2254                if !self
2255                    .state
2256                    .privacy
2257                    .is_tool_enabled("inspect.clear_highlights")
2258                {
2259                    return tool_disabled("inspect.clear_highlights");
2260                }
2261                self.eval_bridge(
2262                    "return window.__VICTAURI__?.clearHighlights()",
2263                    params.webview_label.as_deref(),
2264                )
2265                .await
2266            }
2267            InspectAction::AuditAccessibility => {
2268                self.eval_bridge(
2269                    "return window.__VICTAURI__?.auditAccessibility()",
2270                    params.webview_label.as_deref(),
2271                )
2272                .await
2273            }
2274            InspectAction::GetPerformance => {
2275                self.eval_bridge(
2276                    "return window.__VICTAURI__?.getPerformanceMetrics()",
2277                    params.webview_label.as_deref(),
2278                )
2279                .await
2280            }
2281        }
2282    }
2283
2284    #[tool(
2285        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
2286        annotations(
2287            read_only_hint = false,
2288            destructive_hint = false,
2289            idempotent_hint = true,
2290            open_world_hint = false
2291        )
2292    )]
2293    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
2294        match params.action {
2295            CssAction::Inject => {
2296                if !self.state.privacy.is_tool_enabled("inject_css") {
2297                    return tool_disabled("inject_css");
2298                }
2299                let Some(css) = &params.css else {
2300                    return missing_param("css", "inject");
2301                };
2302                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
2303                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
2304                    return tool_error(e);
2305                }
2306                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
2307                self.eval_bridge(&code, params.webview_label.as_deref())
2308                    .await
2309            }
2310            CssAction::Remove => {
2311                if !self.state.privacy.is_tool_enabled("css.remove") {
2312                    return tool_disabled("css.remove");
2313                }
2314                self.eval_bridge(
2315                    "return window.__VICTAURI__?.removeInjectedCss()",
2316                    params.webview_label.as_deref(),
2317                )
2318                .await
2319            }
2320        }
2321    }
2322
2323    #[tool(
2324        description = "Network request interception (Playwright route() equivalent, no CDP). \
2325            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
2326            Actions:\n\
2327            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
2328              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
2329              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
2330              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
2331            - `list`: list active rules.\n\
2332            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2333            - `matches`: log of intercepted requests.\n\
2334            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2335            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2336            Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2337            fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2338            it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2339            `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2340        annotations(
2341            read_only_hint = false,
2342            destructive_hint = false,
2343            idempotent_hint = false,
2344            open_world_hint = false
2345        )
2346    )]
2347    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2348        match params.action {
2349            RouteAction::Add => {
2350                if !self.state.privacy.is_tool_enabled("route.add") {
2351                    return tool_disabled("route.add");
2352                }
2353                let Some(pattern) = &params.pattern else {
2354                    return missing_param("pattern", "add");
2355                };
2356                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2357                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2358                let mut rule = serde_json::json!({
2359                    "pattern": pattern,
2360                    "match_type": match_type.as_str(),
2361                    "action": behavior.as_str(),
2362                });
2363                if let Some(m) = &params.method {
2364                    rule["method"] = serde_json::json!(m);
2365                }
2366                if let Some(s) = params.status {
2367                    rule["status"] = serde_json::json!(s);
2368                }
2369                if let Some(st) = &params.status_text {
2370                    rule["status_text"] = serde_json::json!(st);
2371                }
2372                if let Some(h) = &params.headers {
2373                    rule["headers"] = h.clone();
2374                }
2375                if let Some(b) = &params.body {
2376                    // A JSON string body is passed through as-is; structured JSON
2377                    // is stringified so the bridge sends valid JSON text.
2378                    rule["body"] = match b {
2379                        serde_json::Value::String(s) => serde_json::json!(s),
2380                        other => serde_json::json!(other.to_string()),
2381                    };
2382                }
2383                if let Some(ct) = &params.content_type {
2384                    rule["content_type"] = serde_json::json!(ct);
2385                }
2386                if let Some(d) = params.delay_ms {
2387                    rule["delay_ms"] = serde_json::json!(d);
2388                }
2389                if let Some(t) = params.times {
2390                    rule["times"] = serde_json::json!(t);
2391                }
2392                let code = format!(
2393                    "return window.__VICTAURI__?.addRoute({})",
2394                    js_string(&rule.to_string())
2395                );
2396                self.eval_bridge(&code, params.webview_label.as_deref())
2397                    .await
2398            }
2399            RouteAction::List => {
2400                self.eval_bridge(
2401                    "return window.__VICTAURI__?.getRouteRules()",
2402                    params.webview_label.as_deref(),
2403                )
2404                .await
2405            }
2406            RouteAction::Clear => {
2407                let Some(id) = params.id else {
2408                    return missing_param("id", "clear");
2409                };
2410                let code = format!("return window.__VICTAURI__?.clearRoute({id})");
2411                self.eval_bridge(&code, params.webview_label.as_deref())
2412                    .await
2413            }
2414            RouteAction::ClearAll => {
2415                self.eval_bridge(
2416                    "return window.__VICTAURI__?.clearRoutes()",
2417                    params.webview_label.as_deref(),
2418                )
2419                .await
2420            }
2421            RouteAction::Matches => {
2422                let limit = params.limit.unwrap_or(100);
2423                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2424                self.eval_bridge(&code, params.webview_label.as_deref())
2425                    .await
2426            }
2427        }
2428    }
2429
2430    #[tool(
2431        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2432            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2433            `logs` (network/console). Actions:\n\
2434            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2435              `with_events=true` to also start the event recorder.\n\
2436            - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2437            - `status`: active flag + buffered frame count.\n\
2438            - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2439        annotations(
2440            read_only_hint = false,
2441            destructive_hint = false,
2442            idempotent_hint = false,
2443            open_world_hint = false
2444        )
2445    )]
2446    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2447        if !self.state.privacy.is_tool_enabled("trace")
2448            || !self.state.privacy.is_tool_enabled("screenshot")
2449        {
2450            return tool_disabled("trace");
2451        }
2452        match params.action {
2453            TraceAction::Start => {
2454                let interval = params.interval_ms.unwrap_or(500);
2455                let max_frames = params.max_frames.unwrap_or(60);
2456                let label = params.webview_label.clone();
2457                let generation = self
2458                    .state
2459                    .screencast
2460                    .start(interval, max_frames, label.clone());
2461
2462                let mut events_started = false;
2463                if params.with_events.unwrap_or(false) {
2464                    let session_id = uuid::Uuid::new_v4().to_string();
2465                    if self.state.recorder.start(session_id).is_ok() {
2466                        events_started = true;
2467                    }
2468                }
2469
2470                // Background capture task: snapshot the window each interval until
2471                // the screencast is stopped (or superseded by a newer start).
2472                let bridge = self.bridge.clone();
2473                let screencast = self.state.screencast.clone();
2474                tokio::spawn(async move {
2475                    let t0 = std::time::Instant::now();
2476                    while screencast.is_active() && screencast.generation() == generation {
2477                        if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2478                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2479                        {
2480                            use base64::Engine;
2481                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2482                            #[allow(clippy::cast_possible_truncation)]
2483                            screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2484                        }
2485                        tokio::time::sleep(std::time::Duration::from_millis(
2486                            screencast.interval_ms(),
2487                        ))
2488                        .await;
2489                    }
2490                });
2491
2492                json_result(&serde_json::json!({
2493                    "started": true,
2494                    "interval_ms": interval.max(50),
2495                    "max_frames": max_frames.clamp(1, 600),
2496                    "with_events": events_started,
2497                }))
2498            }
2499            TraceAction::Stop => {
2500                let frame_count = self.state.screencast.stop();
2501                let timestamps = self.state.screencast.frame_timestamps();
2502                let duration_ms = timestamps.last().copied().unwrap_or(0);
2503                let event_count = self.state.recorder.event_count();
2504                json_result(&serde_json::json!({
2505                    "stopped": true,
2506                    "frame_count": frame_count,
2507                    "duration_ms": duration_ms,
2508                    "frame_timestamps_ms": timestamps,
2509                    "recorded_event_count": event_count,
2510                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2511                }))
2512            }
2513            TraceAction::Status => json_result(&serde_json::json!({
2514                "active": self.state.screencast.is_active(),
2515                "frame_count": self.state.screencast.frame_count(),
2516                "interval_ms": self.state.screencast.interval_ms(),
2517            })),
2518            TraceAction::Frames => {
2519                let limit = params.limit.unwrap_or(0);
2520                let frames = self.state.screencast.frames(limit);
2521                let items: Vec<Content> = frames
2522                    .into_iter()
2523                    .map(|f| Content::image(f.data_b64, "image/png"))
2524                    .collect();
2525                if items.is_empty() {
2526                    return json_result(&serde_json::json!({ "frames": 0 }));
2527                }
2528                CallToolResult::success(items)
2529            }
2530        }
2531    }
2532
2533    #[tool(
2534        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2535            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2536            keyframes, current progress, and the animating element. Standard DOM, so it works \
2537            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2538            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2539              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2540            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2541              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2542              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2543              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2544              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2545              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2546            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2547              on `selector` (or the first animating element); then trigger the animation; then call \
2548              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2549              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2550              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2551            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2552            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2553        annotations(
2554            read_only_hint = true,
2555            destructive_hint = false,
2556            idempotent_hint = true,
2557            open_world_hint = false
2558        )
2559    )]
2560    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2561        if !self.state.privacy.is_tool_enabled("animation") {
2562            return tool_disabled("animation");
2563        }
2564        match params.action {
2565            AnimationAction::List => {
2566                let sel = params
2567                    .selector
2568                    .as_deref()
2569                    .map_or_else(|| "null".to_string(), js_string);
2570                let code = format!(
2571                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2572                );
2573                match self
2574                    .eval_with_return(&code, params.webview_label.as_deref())
2575                    .await
2576                {
2577                    Ok(result_str) => {
2578                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2579                            Ok(v) => json_result(&v),
2580                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2581                        }
2582                    }
2583                    Err(e) => tool_error(format!("animation list failed: {e}")),
2584                }
2585            }
2586            AnimationAction::Scrub => self.animation_scrub(params).await,
2587            AnimationAction::Sample => {
2588                let label = params.webview_label.as_deref();
2589                let sel = params
2590                    .selector
2591                    .as_deref()
2592                    .map_or_else(|| "null".to_string(), js_string);
2593                let code = if params.record.unwrap_or(false) {
2594                    format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2595                } else {
2596                    let clear = params.clear.unwrap_or(false);
2597                    format!("return window.__VICTAURI__.readSweep({clear})")
2598                };
2599                match self.eval_with_return(&code, label).await {
2600                    Ok(result_str) => {
2601                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2602                            Ok(v) => json_result(&v),
2603                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2604                        }
2605                    }
2606                    Err(e) => tool_error(format!("animation sample failed: {e}")),
2607                }
2608            }
2609        }
2610    }
2611
2612    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
2613    /// keep the `#[tool]` method readable.
2614    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2615        let label = params.webview_label.as_deref();
2616        let sel = params
2617            .selector
2618            .as_deref()
2619            .map_or_else(|| "null".to_string(), js_string);
2620
2621        // 1. Prepare: pause the target's animations, learn the timeline length.
2622        let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2623        let prep_v = match self.eval_with_return(&prep_code, label).await {
2624            Ok(s) => {
2625                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2626            }
2627            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2628        };
2629        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2630            // Surface the helpful error/info object (no target, JS-driven, etc.).
2631            return json_result(&prep_v);
2632        }
2633
2634        let points = params.points.unwrap_or(20).clamp(2, 120);
2635        let capture = params.capture.unwrap_or(false);
2636        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2637        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2638        let mut manifest: Vec<serde_json::Value> = Vec::new();
2639
2640        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
2641        for i in 0..points {
2642            #[allow(clippy::cast_precision_loss)]
2643            let progress = i as f64 / (points - 1) as f64;
2644            let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2645            match self.eval_with_return(&seek_code, label).await {
2646                Ok(s) => {
2647                    let v = serde_json::from_str::<serde_json::Value>(&s)
2648                        .unwrap_or(serde_json::Value::Null);
2649                    if capture
2650                        && let Ok(handle) = self.bridge.get_native_handle(label)
2651                        && let Ok((rgba, w, h)) =
2652                            crate::screenshot::capture_window_raw(handle).await
2653                        && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2654                    {
2655                        manifest.push(serde_json::json!({
2656                            "cell": frames.len(),
2657                            "progress": progress,
2658                            "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2659                        }));
2660                        frames.push(frame);
2661                    }
2662                    curve.push(v);
2663                }
2664                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2665            }
2666        }
2667
2668        // 3. Restore (resume) or leave paused.
2669        let resume = params.restore.unwrap_or(true);
2670        let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2671        let _ = self.eval_with_return(&restore_code, label).await;
2672
2673        let mut meta = serde_json::json!({
2674            "scrubbed": true,
2675            "points": points,
2676            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2677            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2678            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2679            "captured": capture,
2680            "curve": curve,
2681        });
2682
2683        // 4. Compose the filmstrip if we captured frames.
2684        if capture && !frames.is_empty() {
2685            let cols = params
2686                .cols
2687                .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2688            if let Some((rgba, w, h)) =
2689                crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2690            {
2691                match crate::screenshot::encode_png(w, h, &rgba) {
2692                    Ok(png) => {
2693                        use base64::Engine;
2694                        let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2695                        meta["filmstrip"] = serde_json::json!({
2696                            "cols": cols,
2697                            "frame_count": frames.len(),
2698                            "width": w,
2699                            "height": h,
2700                            "manifest": manifest,
2701                        });
2702                        return CallToolResult::success(vec![
2703                            Content::image(b64, "image/png"),
2704                            Content::text(meta.to_string()),
2705                        ]);
2706                    }
2707                    Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2708                }
2709            }
2710        }
2711
2712        json_result(&meta)
2713    }
2714
2715    #[tool(
2716        description = "Application logs and monitoring. Actions: console (captured console.log/warn/error), network (intercepted fetch/XHR), ipc (IPC call log — set wait_for_capture=true to await response capture up to 500ms), navigation (URL change history), dialogs (alert/confirm/prompt events), events (combined event stream), slow_ipc (find slow IPC calls).",
2717        annotations(
2718            read_only_hint = true,
2719            destructive_hint = false,
2720            idempotent_hint = true,
2721            open_world_hint = false
2722        )
2723    )]
2724    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2725        match params.action {
2726            LogsAction::Console => {
2727                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2728                let base = if since_arg.is_empty() {
2729                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
2730                } else {
2731                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2732                };
2733                let code = if let Some(limit) = params.limit {
2734                    format!("return ({base} || []).slice(-{limit})")
2735                } else {
2736                    format!("return {base}")
2737                };
2738                self.eval_bridge(&code, params.webview_label.as_deref())
2739                    .await
2740            }
2741            LogsAction::Network => {
2742                let filter_arg = params
2743                    .filter
2744                    .as_ref()
2745                    .map_or_else(|| "null".to_string(), |f| js_string(f));
2746                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2747                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2748                let code = trimmed_log_js(&source, limit);
2749                self.eval_bridge(&code, params.webview_label.as_deref())
2750                    .await
2751            }
2752            LogsAction::Ipc => {
2753                let wait = params.wait_for_capture.unwrap_or(false);
2754                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2755                if wait {
2756                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2757                    let code = format!(
2758                        r"return (async function() {{
2759                            await window.__VICTAURI__.waitForIpcComplete(500);
2760                            return (function() {{ {inner} }})();
2761                        }})()"
2762                    );
2763                    let timeout = std::time::Duration::from_millis(5000);
2764                    match self
2765                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2766                        .await
2767                    {
2768                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2769                        Err(e) => tool_error(e),
2770                    }
2771                } else {
2772                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2773                    self.eval_bridge(&code, params.webview_label.as_deref())
2774                        .await
2775                }
2776            }
2777            LogsAction::Navigation => {
2778                let code = if let Some(limit) = params.limit {
2779                    format!(
2780                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2781                    )
2782                } else {
2783                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
2784                };
2785                self.eval_bridge(&code, params.webview_label.as_deref())
2786                    .await
2787            }
2788            LogsAction::Dialogs => {
2789                let code = if let Some(limit) = params.limit {
2790                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2791                } else {
2792                    "return window.__VICTAURI__?.getDialogLog()".to_string()
2793                };
2794                self.eval_bridge(&code, params.webview_label.as_deref())
2795                    .await
2796            }
2797            LogsAction::Events => {
2798                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2799                let base = if since_arg.is_empty() {
2800                    "window.__VICTAURI__?.getEventStream()".to_string()
2801                } else {
2802                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
2803                };
2804                let code = if let Some(limit) = params.limit {
2805                    format!("return ({base} || []).slice(-{limit})")
2806                } else {
2807                    format!("return {base}")
2808                };
2809                self.eval_bridge(&code, params.webview_label.as_deref())
2810                    .await
2811            }
2812            LogsAction::SlowIpc => {
2813                let Some(threshold) = params.threshold_ms else {
2814                    return missing_param("threshold_ms", "slow_ipc");
2815                };
2816                let limit = params.limit.unwrap_or(20);
2817                let mb = MAX_LOG_FIELD_BYTES;
2818                let code = format!(
2819                    r"return (function() {{
2820                        var MB = {mb};
2821                        function trimField(v) {{
2822                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2823                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2824                            return v;
2825                        }}
2826                        function trimEntry(e) {{ if (e == null || typeof e !== 'object') return e; var o = {{}}; for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) o[k] = trimField(e[k]); }} return o; }}
2827                        var log = window.__VICTAURI__?.getIpcLog() || [];
2828                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2829                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2830                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2831                    }})()",
2832                );
2833                self.eval_bridge(&code, None).await
2834            }
2835            LogsAction::Clear => {
2836                // Clearing the IPC/network logs erases captured evidence — a
2837                // mutation of observable state — so it is gated separately and
2838                // excluded from the read-only Observe profile (red-team P1).
2839                if !self.state.privacy.is_tool_enabled("logs.clear") {
2840                    return tool_disabled("logs.clear");
2841                }
2842                let code = "return (function(){ var b = window.__VICTAURI__; if (!b) return { ok:false, error:'bridge unavailable' }; if (b.clearIpcLog) b.clearIpcLog(); if (b.clearNetworkLog) b.clearNetworkLog(); return { ok:true, cleared:['ipc','network'] }; })()";
2843                self.eval_bridge(code, params.webview_label.as_deref())
2844                    .await
2845            }
2846        }
2847    }
2848
2849    // ── Backend Introspection ────────────────────────────────────────────────
2850
2851    #[tool(
2852        description = "Deep backend introspection — command profiling, IPC contract testing, \
2853            coverage, startup timing, capability auditing, database diagnostics, process \
2854            enumeration, and event bus monitoring. \
2855            These features exploit Victauri's position inside the Rust process.\n\n\
2856            Actions:\n\
2857            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2858            - `coverage`: Which registered commands have been called during this session.\n\
2859            - `command_catalog`: Per-command argument + result SHAPES mined from the live IPC log, merged with the registry — real call/return schemas even for apps that don't use #[inspectable] (where get_registry is names-only). The highest-signal way to learn how to drive an app's commands.\n\
2860            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2861            - `contract_check`: Check all recorded contracts for schema drift.\n\
2862            - `contract_list`: List all recorded contract baselines.\n\
2863            - `contract_clear`: Clear all recorded contract baselines.\n\
2864            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2865            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2866            - `db_health`: Read-only SQLite database diagnostics (journal mode, WAL presence, page stats).\n\
2867            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2868            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2869            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2870            - `event_bus`: List captured Tauri events + app events (auto-intercepted via listen_any — no app opt-in needed). Returns the newest events per category (default 100) so the full buffers (up to ~11k events / megabytes) never overflow the result; `count` is the true total and `truncated` flags a capped slice. Scope via the `args` object: `{\"action\":\"event_bus\",\"args\":{\"limit\":500,\"since_ms\":5000}}`.\n\
2871            - `event_bus_clear`: Clear the event bus capture buffer.",
2872        annotations(
2873            read_only_hint = true,
2874            destructive_hint = false,
2875            idempotent_hint = true,
2876            open_world_hint = false
2877        )
2878    )]
2879    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2880        if !self.state.privacy.is_tool_enabled("introspect") {
2881            return tool_disabled("introspect");
2882        }
2883
2884        match params.action {
2885            IntrospectAction::CommandTimings => {
2886                let mut stats = self.state.command_timings.all_stats();
2887                let driven_count = stats.len();
2888                if let Some(threshold) = params.slow_threshold_ms {
2889                    stats.retain(|s| s.avg_ms >= threshold);
2890                }
2891
2892                // Real frontend traffic: derive per-command latency from the live IPC
2893                // log so the profiler is not blind to commands the app itself drives.
2894                // `command_timings` (above) only records Victauri-driven invoke_command
2895                // calls — on a running app that counter is typically 0 while the app
2896                // makes hundreds of real calls. The IPC log captures those with
2897                // duration; the name+duration projection stays under the eval cap.
2898                let code = ipc_timing_projection_js(None);
2899                let mut ipc_traffic = match self
2900                    .eval_with_return(&code, params.webview_label.as_deref())
2901                    .await
2902                {
2903                    Ok(json_str) => serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2904                        .map(|entries| ipc_timing_stats(&entries))
2905                        .unwrap_or_default(),
2906                    Err(_) => Vec::new(),
2907                };
2908                if let Some(threshold) = params.slow_threshold_ms {
2909                    ipc_traffic.retain(|s| {
2910                        s.get("avg_ms")
2911                            .and_then(serde_json::Value::as_f64)
2912                            .is_some_and(|a| a >= threshold)
2913                    });
2914                }
2915
2916                let result = serde_json::json!({
2917                    "commands": stats,
2918                    "total_commands_profiled": driven_count,
2919                    "ipc_traffic": ipc_traffic,
2920                    "ipc_commands_observed": ipc_traffic.len(),
2921                    "slow_threshold_ms": params.slow_threshold_ms,
2922                    "note": "`commands` profiles ONLY commands you drove through Victauri's \
2923                             invoke_command tool (often empty on a live app). `ipc_traffic` \
2924                             profiles the app's REAL frontend IPC, derived from the live IPC \
2925                             log (per-command call_count + min/max/avg/p95 latency) — that is \
2926                             the one reflecting actual usage.",
2927                });
2928                json_result(&result)
2929            }
2930            IntrospectAction::Coverage => {
2931                let registered: Vec<String> = self
2932                    .state
2933                    .registry
2934                    .list()
2935                    .iter()
2936                    .map(|c| c.name.clone())
2937                    .collect();
2938
2939                // Project to command NAMES ONLY. The previous full `getIpcLog()` carried
2940                // request/response bodies and blew the eval result cap on busy apps,
2941                // silently returning an empty set and reporting "0 invoked" despite live
2942                // traffic. This is the same name projection ghost detection uses.
2943                let code = ghost_ipc_projection_js(None);
2944                let (invoked, ipc_calls_observed): (std::collections::HashSet<String>, usize) =
2945                    match self
2946                        .eval_with_return(&code, params.webview_label.as_deref())
2947                        .await
2948                    {
2949                        Ok(json_str) => match serde_json::from_str::<Vec<String>>(&json_str) {
2950                            Ok(names) => {
2951                                let count = names.len();
2952                                (names.into_iter().collect(), count)
2953                            }
2954                            Err(_) => (std::collections::HashSet::new(), 0),
2955                        },
2956                        Err(_) => (std::collections::HashSet::new(), 0),
2957                    };
2958
2959                let uncovered: Vec<&String> = registered
2960                    .iter()
2961                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2962                    .collect();
2963
2964                let coverage_pct = if registered.is_empty() {
2965                    100.0
2966                } else {
2967                    let covered = registered.len() - uncovered.len();
2968                    (covered as f64 / registered.len() as f64) * 100.0
2969                };
2970
2971                let note = if registered.is_empty() {
2972                    Some(
2973                        "The introspection registry is empty (the app does not use \
2974                         #[inspectable]/register_command_names), so coverage_pct is a \
2975                         placeholder 100%. `invoked_not_registered` still lists the real \
2976                         commands seen on the live IPC log — use it to inventory actual \
2977                         traffic.",
2978                    )
2979                } else if ipc_calls_observed == 0 {
2980                    Some(
2981                        "No IPC calls were observed on the live log. If the app is actively \
2982                         making calls, confirm the target webview and that Tauri IPC routes \
2983                         through fetch to ipc.localhost (some commands use the native channel).",
2984                    )
2985                } else {
2986                    None
2987                };
2988
2989                let result = serde_json::json!({
2990                    "registered_commands": registered.len(),
2991                    "invoked_commands": invoked.len(),
2992                    "ipc_calls_observed": ipc_calls_observed,
2993                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2994                    "uncovered": uncovered,
2995                    "invoked_not_registered": invoked.iter()
2996                        .filter(|cmd| !registered.contains(cmd))
2997                        .collect::<Vec<_>>(),
2998                    "note": note,
2999                });
3000                json_result(&result)
3001            }
3002            IntrospectAction::CommandCatalog => {
3003                // Mine the live IPC log for per-command argument + result SHAPES (inferred
3004                // in JS, bodies never shipped — so it stays under the eval cap on busy apps)
3005                // and merge with the #[inspectable] registry. This is the answer to a real
3006                // live gap: an app without #[inspectable] (e.g. 4DA — 379 commands, every
3007                // registry field null) gives an agent command NAMES but no call/return
3008                // schema; the IPC log holds the actual shapes, so we project them out.
3009                let code = ipc_catalog_projection_js();
3010                let ipc_entries: Vec<serde_json::Value> = match self
3011                    .eval_with_return(&code, params.webview_label.as_deref())
3012                    .await
3013                {
3014                    Ok(json_str) => serde_json::from_str(&json_str).unwrap_or_default(),
3015                    Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
3016                };
3017
3018                let registry = self.state.registry.list();
3019                let catalog = merge_command_catalog(&ipc_entries, &registry);
3020                let observed = catalog
3021                    .iter()
3022                    .filter(|c| {
3023                        c.get("observed")
3024                            .and_then(serde_json::Value::as_bool)
3025                            .unwrap_or(false)
3026                    })
3027                    .count();
3028
3029                let result = serde_json::json!({
3030                    "catalog": catalog,
3031                    "observed_count": observed,
3032                    "registered_count": registry.len(),
3033                    "total": catalog.len(),
3034                    "note": "`arg_shape`/`result_shape` are STRUCTURES inferred from the live \
3035                             IPC log (keys + value types, not values) — how to CALL each command \
3036                             and what it RETURNS, even for apps that don't use #[inspectable]. \
3037                             `observed:false` means the command is in the registry but hasn't \
3038                             been seen on the wire this session (drive the app's UI to populate \
3039                             its shape). `declared_*` fields, when present, come from \
3040                             #[inspectable] and are authoritative over the inferred shape.",
3041                });
3042                json_result(&result)
3043            }
3044            IntrospectAction::ContractRecord => {
3045                let Some(command) = params.command else {
3046                    return missing_param("command", "contract_record");
3047                };
3048                // contract_record invokes the command with caller-supplied args, so
3049                // it must honour the same allow/blocklist as invoke_command (audit #30).
3050                if !self.state.privacy.is_invoke_allowed(&command)
3051                    || !self.state.privacy.is_command_allowed(&command)
3052                {
3053                    return tool_error(format!(
3054                        "command '{command}' is blocked by privacy configuration"
3055                    ));
3056                }
3057                let args_json = params.args.unwrap_or(serde_json::json!({}));
3058                let args_str =
3059                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
3060                let code = format!(
3061                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3062                    js_string(&command)
3063                );
3064                match self
3065                    .eval_with_return(&code, params.webview_label.as_deref())
3066                    .await
3067                {
3068                    Ok(result_str) => {
3069                        let value: serde_json::Value = serde_json::from_str(&result_str)
3070                            .unwrap_or(serde_json::Value::String(result_str.clone()));
3071                        let shape = crate::introspection::JsonShape::from_value(&value);
3072                        let sample = if result_str.len() > 4096 {
3073                            format!("{}...(truncated)", &result_str[..4096])
3074                        } else {
3075                            result_str
3076                        };
3077                        let baseline = crate::introspection::ContractBaseline {
3078                            command: command.clone(),
3079                            args: args_json,
3080                            shape: shape.clone(),
3081                            sample,
3082                            recorded_at: chrono_now(),
3083                        };
3084                        self.state.contract_store.record(baseline);
3085                        let result = serde_json::json!({
3086                            "recorded": true,
3087                            "command": command,
3088                            "shape_type": shape.type_name(),
3089                        });
3090                        json_result(&result)
3091                    }
3092                    Err(e) => tool_error(format!(
3093                        "failed to invoke '{command}' for contract recording: {e}"
3094                    )),
3095                }
3096            }
3097            IntrospectAction::ContractCheck => {
3098                let baselines = self.state.contract_store.all();
3099                if baselines.is_empty() {
3100                    return json_result(&serde_json::json!({
3101                        "checked": 0,
3102                        "message": "no contract baselines recorded — use contract_record first",
3103                    }));
3104                }
3105                let mut results = Vec::new();
3106                for baseline in &baselines {
3107                    // Re-checking a baseline re-invokes the command; honour the
3108                    // allow/blocklist in case it changed since recording (audit #30).
3109                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
3110                        || !self.state.privacy.is_command_allowed(&baseline.command)
3111                    {
3112                        continue;
3113                    }
3114                    let args_str =
3115                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
3116                    let code = format!(
3117                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
3118                        js_string(&baseline.command)
3119                    );
3120                    match self
3121                        .eval_with_return(&code, params.webview_label.as_deref())
3122                        .await
3123                    {
3124                        Ok(result_str) => {
3125                            let value: serde_json::Value = serde_json::from_str(&result_str)
3126                                .unwrap_or(serde_json::Value::String(result_str));
3127                            let current_shape = crate::introspection::JsonShape::from_value(&value);
3128                            let drift = crate::introspection::diff_shapes(
3129                                &baseline.shape,
3130                                &current_shape,
3131                                &baseline.command,
3132                            );
3133                            results.push(drift);
3134                        }
3135                        Err(e) => {
3136                            results.push(crate::introspection::ContractDrift {
3137                                command: baseline.command.clone(),
3138                                new_fields: Vec::new(),
3139                                removed_fields: Vec::new(),
3140                                type_changes: Vec::new(),
3141                                shape_matches: false,
3142                            });
3143                            tracing::warn!(
3144                                command = %baseline.command,
3145                                error = %e,
3146                                "contract check invocation failed"
3147                            );
3148                        }
3149                    }
3150                }
3151                let passing = results.iter().filter(|r| r.shape_matches).count();
3152                let result = serde_json::json!({
3153                    "checked": results.len(),
3154                    "passing": passing,
3155                    "failing": results.len() - passing,
3156                    "contracts": results,
3157                });
3158                json_result(&result)
3159            }
3160            IntrospectAction::ContractList => {
3161                let baselines = self.state.contract_store.all();
3162                let result = serde_json::json!({
3163                    "count": baselines.len(),
3164                    "baselines": baselines.iter().map(|b| serde_json::json!({
3165                        "command": b.command,
3166                        "shape_type": b.shape.type_name(),
3167                        "recorded_at": b.recorded_at,
3168                    })).collect::<Vec<_>>(),
3169                });
3170                json_result(&result)
3171            }
3172            IntrospectAction::ContractClear => {
3173                let cleared = self.state.contract_store.clear();
3174                json_result(&serde_json::json!({
3175                    "cleared": cleared,
3176                }))
3177            }
3178            IntrospectAction::StartupTiming => {
3179                let phases = self.state.startup_timeline.report();
3180                let result = serde_json::json!({
3181                    "phases": phases,
3182                    "total_ms": self.state.startup_timeline.total_ms(),
3183                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3184                });
3185                json_result(&result)
3186            }
3187            IntrospectAction::Capabilities => {
3188                let config = self.bridge.tauri_config();
3189                let live_windows = self.bridge.list_window_labels();
3190
3191                let result = serde_json::json!({
3192                    "app": {
3193                        "identifier": config.get("identifier"),
3194                        "product_name": config.get("product_name"),
3195                        "version": config.get("version"),
3196                    },
3197                    "security": config.get("security"),
3198                    "configured_windows": config.get("windows"),
3199                    "live_windows": live_windows,
3200                    "configured_plugins": config.get("plugins"),
3201                    "victauri": {
3202                        "registered_commands": self.state.registry.list().len(),
3203                        "redaction_enabled": self.state.privacy.redaction_enabled,
3204                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3205                        "disabled_tools": &self.state.privacy.disabled_tools,
3206                    },
3207                });
3208                json_result(&result)
3209            }
3210            #[allow(unused_variables)]
3211            IntrospectAction::DbHealth => {
3212                #[cfg(feature = "sqlite")]
3213                {
3214                    let db_path = params.db_path.clone();
3215                    match self.run_db_health(db_path.as_deref()).await {
3216                        Ok(health) => json_result(&health),
3217                        Err(e) => tool_error(format!("db_health failed: {e}")),
3218                    }
3219                }
3220                #[cfg(not(feature = "sqlite"))]
3221                {
3222                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3223                }
3224            }
3225            IntrospectAction::PluginState => {
3226                let recording_active = self.state.recorder.is_recording();
3227                let recording_events = self.state.recorder.event_count();
3228                let result = serde_json::json!({
3229                    "event_log": {
3230                        "size": self.state.event_log.len(),
3231                        "capacity": self.state.event_log.capacity(),
3232                    },
3233                    "registry": {
3234                        "commands_registered": self.state.registry.list().len(),
3235                    },
3236                    "recording": {
3237                        "active": recording_active,
3238                        "events_captured": recording_events,
3239                    },
3240                    "faults": {
3241                        "active_rules": self.state.fault_registry.list().len(),
3242                    },
3243                    "contracts": {
3244                        "baselines_recorded": self.state.contract_store.all().len(),
3245                    },
3246                    "timings": {
3247                        "commands_profiled": self.state.command_timings.all_stats().len(),
3248                    },
3249                    "event_bus": {
3250                        "captured_events": self.state.event_bus.len(),
3251                    },
3252                    "tasks": {
3253                        "total": self.state.task_tracker.list().len(),
3254                        "active": self.state.task_tracker.active_count(),
3255                    },
3256                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3257                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3258                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3259                });
3260                json_result(&result)
3261            }
3262            IntrospectAction::Processes => {
3263                let pid = std::process::id();
3264                let uptime = self.state.started_at.elapsed();
3265                let children = crate::introspection::enumerate_child_processes();
3266                let host_memory = crate::memory::current_stats();
3267
3268                let result = serde_json::json!({
3269                    "host": {
3270                        "pid": pid,
3271                        "uptime_secs": uptime.as_secs(),
3272                        "platform": std::env::consts::OS,
3273                        "arch": std::env::consts::ARCH,
3274                        "memory": host_memory,
3275                    },
3276                    "children": children.iter().map(|c| serde_json::json!({
3277                        "pid": c.pid,
3278                        "name": c.name,
3279                        "memory_bytes": c.memory_bytes,
3280                    })).collect::<Vec<_>>(),
3281                    "child_count": children.len(),
3282                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3283                });
3284                json_result(&result)
3285            }
3286            IntrospectAction::PluginTasks => {
3287                let tasks = self.state.task_tracker.list();
3288                let active = self.state.task_tracker.active_count();
3289                let result = serde_json::json!({
3290                    "total": tasks.len(),
3291                    "active": active,
3292                    "finished": tasks.len() - active,
3293                    "tasks": tasks,
3294                });
3295                json_result(&result)
3296            }
3297            IntrospectAction::EventBus => {
3298                // Default cap so the full buffers (up to 1k Tauri + 10k app events, often
3299                // megabytes / tens of thousands of lines) can never overflow the tool result
3300                // cap (VIC-4). Newest events first; `count` is the full total so a truncated
3301                // slice is always diagnosable. Optional `limit` / `since_ms` are read from the
3302                // generic `args` object (a dedicated public field would be a semver-major break).
3303                let opts = params.args.as_ref();
3304                let limit = opts
3305                    .and_then(|a| a.get("limit"))
3306                    .and_then(serde_json::Value::as_u64)
3307                    .and_then(|n| usize::try_from(n).ok())
3308                    .unwrap_or(100);
3309                let since_ms = opts
3310                    .and_then(|a| a.get("since_ms"))
3311                    .and_then(serde_json::Value::as_u64);
3312                let cutoff = since_ms.map(|ms| {
3313                    chrono::Utc::now()
3314                        - chrono::TimeDelta::milliseconds(i64::try_from(ms).unwrap_or(i64::MAX))
3315                });
3316
3317                let all_tauri = self.state.event_bus.events();
3318                let tauri_total = all_tauri.len();
3319                let tauri_matched: Vec<_> = all_tauri
3320                    .into_iter()
3321                    .filter(|e| match cutoff {
3322                        Some(cut) => chrono::DateTime::parse_from_rfc3339(&e.timestamp)
3323                            .map_or(true, |t| t.with_timezone(&chrono::Utc) >= cut),
3324                        None => true,
3325                    })
3326                    .collect();
3327                let tauri_matched_count = tauri_matched.len();
3328                let tauri_events: Vec<_> = tauri_matched.into_iter().rev().take(limit).collect();
3329
3330                // Exclude Victauri's own infrastructure events (plugin:victauri|* IPC etc.) —
3331                // noise in a diagnostic timeline; the `explain` tools already filter them via
3332                // `is_internal()`.
3333                let all_app: Vec<_> = self
3334                    .state
3335                    .event_log
3336                    .snapshot()
3337                    .into_iter()
3338                    .filter(|e| !e.is_internal())
3339                    .collect();
3340                let app_total = all_app.len();
3341                let app_matched: Vec<_> = match cutoff {
3342                    Some(cut) => all_app
3343                        .into_iter()
3344                        .filter(|e| e.timestamp() >= cut)
3345                        .collect(),
3346                    None => all_app,
3347                };
3348                let app_matched_count = app_matched.len();
3349                let app_events: Vec<_> = app_matched.into_iter().rev().take(limit).collect();
3350
3351                let result = serde_json::json!({
3352                    "limit": limit,
3353                    "since_ms": since_ms,
3354                    "tauri_events": {
3355                        "count": tauri_total,
3356                        "matched": tauri_matched_count,
3357                        "returned": tauri_events.len(),
3358                        "truncated": tauri_matched_count > tauri_events.len(),
3359                        "events": tauri_events,
3360                    },
3361                    "app_events": {
3362                        "count": app_total,
3363                        "matched": app_matched_count,
3364                        "returned": app_events.len(),
3365                        "truncated": app_matched_count > app_events.len(),
3366                        "capacity": self.state.event_log.capacity(),
3367                        "events": app_events,
3368                    },
3369                });
3370                json_result(&result)
3371            }
3372            IntrospectAction::EventBusClear => {
3373                let tauri_cleared = self.state.event_bus.clear();
3374                self.state.event_log.clear();
3375                json_result(&serde_json::json!({
3376                    "tauri_events_cleared": tauri_cleared,
3377                    "app_events_cleared": true,
3378                }))
3379            }
3380        }
3381    }
3382
3383    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3384
3385    #[tool(
3386        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3387            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3388            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3389            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3390            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3391            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3392            Actions:\n\
3393            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
3394            - `list`: List all active fault injection rules.\n\
3395            - `clear`: Remove a specific fault rule (requires `command`).\n\
3396            - `clear_all`: Remove all fault rules.",
3397        annotations(
3398            read_only_hint = false,
3399            destructive_hint = true,
3400            idempotent_hint = false,
3401            open_world_hint = false
3402        )
3403    )]
3404    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3405        if !self.state.privacy.is_tool_enabled("fault") {
3406            return tool_disabled("fault");
3407        }
3408
3409        match params.action {
3410            FaultAction::Inject => {
3411                let Some(command) = params.command else {
3412                    return missing_param("command", "inject");
3413                };
3414                let Some(fault_kind) = params.fault_type else {
3415                    return missing_param("fault_type", "inject");
3416                };
3417                let fault_type = match fault_kind {
3418                    FaultKind::Delay => {
3419                        let delay_ms = params.delay_ms.unwrap_or(1000);
3420                        crate::introspection::FaultType::Delay { delay_ms }
3421                    }
3422                    FaultKind::Error => {
3423                        let message = params
3424                            .error_message
3425                            .unwrap_or_else(|| "injected fault".to_string());
3426                        crate::introspection::FaultType::Error { message }
3427                    }
3428                    FaultKind::Drop => crate::introspection::FaultType::Drop,
3429                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3430                };
3431                let config = crate::introspection::FaultConfig {
3432                    command: command.clone(),
3433                    fault_type: fault_type.clone(),
3434                    trigger_count: 0,
3435                    max_triggers: params.max_triggers.unwrap_or(0),
3436                    created_at: std::time::Instant::now(),
3437                };
3438                self.state.fault_registry.inject(config);
3439                let result = serde_json::json!({
3440                    "injected": true,
3441                    "command": command,
3442                    "fault_type": fault_type,
3443                    "max_triggers": params.max_triggers.unwrap_or(0),
3444                });
3445                json_result(&result)
3446            }
3447            FaultAction::List => {
3448                let faults = self.state.fault_registry.list();
3449                let result = serde_json::json!({
3450                    "count": faults.len(),
3451                    "faults": faults.iter().map(|f| serde_json::json!({
3452                        "command": f.command,
3453                        "fault_type": f.fault_type,
3454                        "trigger_count": f.trigger_count,
3455                        "max_triggers": f.max_triggers,
3456                    })).collect::<Vec<_>>(),
3457                });
3458                json_result(&result)
3459            }
3460            FaultAction::Clear => {
3461                let Some(command) = params.command else {
3462                    return missing_param("command", "clear");
3463                };
3464                let removed = self.state.fault_registry.clear(&command);
3465                json_result(&serde_json::json!({
3466                    "removed": removed,
3467                    "command": command,
3468                }))
3469            }
3470            FaultAction::ClearAll => {
3471                let removed = self.state.fault_registry.clear_all();
3472                json_result(&serde_json::json!({
3473                    "removed": removed,
3474                }))
3475            }
3476        }
3477    }
3478
3479    // ── Cross-Layer Explanation ────────────────────────────────────────────
3480
3481    #[tool(
3482        description = "Correlate recent activity across all layers into a coherent narrative. \
3483            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3484            + window events across the Rust backend and webview simultaneously.\n\n\
3485            Actions:\n\
3486            - `summary`: High-level activity summary for the last N seconds (default 30). \
3487              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3488            - `last_action`: Correlate the most recent burst of events into a causal timeline \
3489              (e.g. 'IPC call → DOM update → console.log').\n\
3490            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3491        annotations(
3492            read_only_hint = true,
3493            destructive_hint = false,
3494            idempotent_hint = true,
3495            open_world_hint = false
3496        )
3497    )]
3498    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3499        if !self.state.privacy.is_tool_enabled("explain") {
3500            return tool_disabled("explain");
3501        }
3502
3503        match params.action {
3504            ExplainAction::Summary => {
3505                let secs = params.seconds.unwrap_or(30);
3506                let since = chrono::Utc::now()
3507                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3508                let events = self.state.event_log.since(since);
3509
3510                let mut ipc_count = 0u64;
3511                let mut dom_mutations = 0u64;
3512                let mut state_changes = 0u64;
3513                let mut console_count = 0u64;
3514                let mut window_events = 0u64;
3515                let mut interactions = 0u64;
3516                let mut top_commands: HashMap<String, u64> = HashMap::new();
3517                let mut errors: Vec<String> = Vec::new();
3518
3519                for event in &events {
3520                    match event {
3521                        victauri_core::AppEvent::Ipc(call) => {
3522                            ipc_count += 1;
3523                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3524                            if let victauri_core::IpcResult::Err(e) = &call.result {
3525                                errors.push(format!("IPC {}: {e}", call.command));
3526                            }
3527                        }
3528                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3529                            dom_mutations += u64::from(*mutation_count)
3530                        }
3531                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3532                        victauri_core::AppEvent::Console { level, message, .. } => {
3533                            console_count += 1;
3534                            if level == "error" {
3535                                errors.push(format!("console.error: {message}"));
3536                            }
3537                        }
3538                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3539                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3540                        _ => {}
3541                    }
3542                }
3543
3544                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3545                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3546                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3547
3548                let narrative = format!(
3549                    "{ipc_count} IPC call{} in the last {secs}s{}. \
3550                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3551                     {console_count} console message{}, {window_events} window event{}. {}.",
3552                    if ipc_count == 1 { "" } else { "s" },
3553                    if top.is_empty() {
3554                        String::new()
3555                    } else {
3556                        format!(
3557                            ", dominated by {}",
3558                            top.iter()
3559                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3560                                .collect::<Vec<_>>()
3561                                .join(", ")
3562                        )
3563                    },
3564                    if dom_mutations == 1 { "" } else { "s" },
3565                    if interactions == 1 { "" } else { "s" },
3566                    if console_count == 1 { "" } else { "s" },
3567                    if window_events == 1 { "" } else { "s" },
3568                    if errors.is_empty() {
3569                        "No errors".to_string()
3570                    } else {
3571                        format!(
3572                            "{} error{}",
3573                            errors.len(),
3574                            if errors.len() == 1 { "" } else { "s" }
3575                        )
3576                    },
3577                );
3578
3579                let result = serde_json::json!({
3580                    "time_window_secs": secs,
3581                    "total_events": events.len(),
3582                    "ipc_calls": ipc_count,
3583                    "dom_mutations": dom_mutations,
3584                    "state_changes": state_changes,
3585                    "console_messages": console_count,
3586                    "window_events": window_events,
3587                    "interactions": interactions,
3588                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3589                        serde_json::json!({"command": cmd, "count": n})
3590                    }).collect::<Vec<_>>(),
3591                    "errors": errors,
3592                    "narrative": narrative,
3593                });
3594                json_result(&result)
3595            }
3596            ExplainAction::LastAction => {
3597                let secs = params.seconds.unwrap_or(5);
3598                let since = chrono::Utc::now()
3599                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3600                let events = self.state.event_log.since(since);
3601
3602                let timeline: Vec<serde_json::Value> = events
3603                    .iter()
3604                    .filter(|e| !e.is_internal())
3605                    .map(|event| match event {
3606                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3607                            "time": call.timestamp.to_rfc3339_opts(
3608                                chrono::SecondsFormat::Millis, true
3609                            ),
3610                            "type": "ipc",
3611                            "detail": format!(
3612                                "{} {} ({}ms)",
3613                                call.command,
3614                                call.result,
3615                                call.duration_ms.unwrap_or(0)
3616                            ),
3617                        }),
3618                        victauri_core::AppEvent::DomMutation {
3619                            timestamp,
3620                            mutation_count,
3621                            webview_label,
3622                        } => serde_json::json!({
3623                            "time": timestamp.to_rfc3339_opts(
3624                                chrono::SecondsFormat::Millis, true
3625                            ),
3626                            "type": "dom_mutation",
3627                            "detail": format!(
3628                                "{mutation_count} element{} updated in {webview_label}",
3629                                if *mutation_count == 1 { "" } else { "s" }
3630                            ),
3631                        }),
3632                        victauri_core::AppEvent::DomInteraction {
3633                            timestamp,
3634                            action,
3635                            selector,
3636                            ..
3637                        } => serde_json::json!({
3638                            "time": timestamp.to_rfc3339_opts(
3639                                chrono::SecondsFormat::Millis, true
3640                            ),
3641                            "type": "interaction",
3642                            "detail": format!("{action} on {selector}"),
3643                        }),
3644                        victauri_core::AppEvent::StateChange {
3645                            timestamp,
3646                            key,
3647                            caused_by,
3648                        } => serde_json::json!({
3649                            "time": timestamp.to_rfc3339_opts(
3650                                chrono::SecondsFormat::Millis, true
3651                            ),
3652                            "type": "state_change",
3653                            "detail": format!(
3654                                "{key} changed{}",
3655                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3656                            ),
3657                        }),
3658                        victauri_core::AppEvent::Console {
3659                            timestamp,
3660                            level,
3661                            message,
3662                        } => serde_json::json!({
3663                            "time": timestamp.to_rfc3339_opts(
3664                                chrono::SecondsFormat::Millis, true
3665                            ),
3666                            "type": "console",
3667                            "detail": format!("console.{level}: {message}"),
3668                        }),
3669                        victauri_core::AppEvent::WindowEvent {
3670                            timestamp,
3671                            label,
3672                            event,
3673                        } => serde_json::json!({
3674                            "time": timestamp.to_rfc3339_opts(
3675                                chrono::SecondsFormat::Millis, true
3676                            ),
3677                            "type": "window_event",
3678                            "detail": format!("{event} on window '{label}'"),
3679                        }),
3680                        _ => serde_json::json!({
3681                            "time": event.timestamp().to_rfc3339_opts(
3682                                chrono::SecondsFormat::Millis, true
3683                            ),
3684                            "type": "other",
3685                            "detail": "unknown event type",
3686                        }),
3687                    })
3688                    .collect();
3689
3690                let narrative = if timeline.is_empty() {
3691                    format!("No activity in the last {secs}s.")
3692                } else {
3693                    let parts: Vec<String> = timeline
3694                        .iter()
3695                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3696                        .map(String::from)
3697                        .collect();
3698                    parts.join(" → ")
3699                };
3700
3701                let result = serde_json::json!({
3702                    "time_window_secs": secs,
3703                    "event_count": timeline.len(),
3704                    "timeline": timeline,
3705                    "narrative": narrative,
3706                });
3707                json_result(&result)
3708            }
3709            ExplainAction::Diff => {
3710                let secs = params.seconds.unwrap_or(10);
3711                let since = chrono::Utc::now()
3712                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3713                let events = self.state.event_log.since(since);
3714
3715                let mut ipc_commands: Vec<String> = Vec::new();
3716                let mut dom_changes = 0u64;
3717                let mut error_count = 0u64;
3718                let mut interaction_count = 0u64;
3719                let mut console_messages = 0u64;
3720
3721                for event in &events {
3722                    if event.is_internal() {
3723                        continue;
3724                    }
3725                    match event {
3726                        victauri_core::AppEvent::Ipc(call) => {
3727                            ipc_commands.push(call.command.clone());
3728                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3729                                error_count += 1;
3730                            }
3731                        }
3732                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3733                            dom_changes += u64::from(*mutation_count)
3734                        }
3735                        victauri_core::AppEvent::DomInteraction { .. } => {
3736                            interaction_count += 1;
3737                        }
3738                        victauri_core::AppEvent::Console { level, .. } => {
3739                            console_messages += 1;
3740                            if level == "error" {
3741                                error_count += 1;
3742                            }
3743                        }
3744                        _ => {}
3745                    }
3746                }
3747
3748                ipc_commands.dedup();
3749
3750                let result = serde_json::json!({
3751                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3752                    "time_window_secs": secs,
3753                    "total_events": events.len(),
3754                    "ipc_calls_made": ipc_commands.len(),
3755                    "unique_commands": ipc_commands,
3756                    "dom_elements_changed": dom_changes,
3757                    "interactions": interaction_count,
3758                    "console_messages": console_messages,
3759                    "errors": error_count,
3760                });
3761                json_result(&result)
3762            }
3763        }
3764    }
3765}
3766
3767impl VictauriMcpHandler {
3768    /// Create a new handler backed by the given state and webview bridge.
3769    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3770        Self {
3771            state,
3772            bridge,
3773            subscriptions: Arc::new(Mutex::new(HashSet::new())),
3774            bridge_checked: Arc::new(AtomicBool::new(false)),
3775            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3776        }
3777    }
3778
3779    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3780        self.state.privacy.is_tool_enabled(name)
3781    }
3782
3783    pub(crate) async fn execute_tool(
3784        &self,
3785        name: &str,
3786        args: serde_json::Value,
3787    ) -> Result<CallToolResult, rest::ToolCallError> {
3788        // Centralized authorization: resolve the canonical `tool.action` capability
3789        // and gate on it BEFORE dispatch, so every compound action is checked
3790        // uniformly (not just the ones whose handler remembers to). See `authz`.
3791        let capability = authz::canonical_capability(name, &args);
3792        if !self.state.privacy.is_call_allowed(name, &capability) {
3793            return Ok(tool_disabled(&capability));
3794        }
3795        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3796        let start = std::time::Instant::now();
3797        tracing::debug!(tool = %name, "REST tool invocation started");
3798
3799        let result = match name {
3800            "eval_js" => {
3801                let p: EvalJsParams = Self::parse_args(args)?;
3802                self.eval_js(Parameters(p)).await
3803            }
3804            "dom_snapshot" => {
3805                let p: SnapshotParams = Self::parse_args(args)?;
3806                self.dom_snapshot(Parameters(p)).await
3807            }
3808            "find_elements" => {
3809                let p: FindElementsParams = Self::parse_args(args)?;
3810                self.find_elements(Parameters(p)).await
3811            }
3812            "invoke_command" => {
3813                let p: InvokeCommandParams = Self::parse_args(args)?;
3814                self.invoke_command(Parameters(p)).await
3815            }
3816            "screenshot" => {
3817                let p: ScreenshotParams = Self::parse_args(args)?;
3818                self.screenshot(Parameters(p)).await
3819            }
3820            "verify_state" => {
3821                let p: VerifyStateParams = Self::parse_args(args)?;
3822                self.verify_state(Parameters(p)).await
3823            }
3824            "detect_ghost_commands" => {
3825                let p: GhostCommandParams = Self::parse_args(args)?;
3826                self.detect_ghost_commands(Parameters(p)).await
3827            }
3828            "check_ipc_integrity" => {
3829                let p: IpcIntegrityParams = Self::parse_args(args)?;
3830                self.check_ipc_integrity(Parameters(p)).await
3831            }
3832            "wait_for" => {
3833                let p: WaitForParams = Self::parse_args(args)?;
3834                self.wait_for(Parameters(p)).await
3835            }
3836            "assert_semantic" => {
3837                let p: SemanticAssertParams = Self::parse_args(args)?;
3838                self.assert_semantic(Parameters(p)).await
3839            }
3840            "resolve_command" => {
3841                let p: ResolveCommandParams = Self::parse_args(args)?;
3842                self.resolve_command(Parameters(p)).await
3843            }
3844            "get_registry" => {
3845                let p: RegistryParams = Self::parse_args(args)?;
3846                self.get_registry(Parameters(p)).await
3847            }
3848            "app_state" => {
3849                let p: AppStateParams = Self::parse_args(args)?;
3850                self.app_state(Parameters(p)).await
3851            }
3852            "get_memory_stats" => self.get_memory_stats().await,
3853            "get_plugin_info" => self.get_plugin_info().await,
3854            "get_diagnostics" => {
3855                let p: DiagnosticsParams = Self::parse_args(args)?;
3856                self.get_diagnostics(Parameters(p)).await
3857            }
3858            "app_info" => self.app_info().await,
3859            "list_app_dir" => {
3860                let p: ListAppDirParams = Self::parse_args(args)?;
3861                self.list_app_dir(Parameters(p)).await
3862            }
3863            "read_app_file" => {
3864                let p: ReadAppFileParams = Self::parse_args(args)?;
3865                self.read_app_file(Parameters(p)).await
3866            }
3867            "query_db" => {
3868                let p: QueryDbParams = Self::parse_args(args)?;
3869                self.query_db(Parameters(p)).await
3870            }
3871            "interact" => {
3872                let p: InteractParams = Self::parse_args(args)?;
3873                self.interact(Parameters(p)).await
3874            }
3875            "input" => {
3876                let p: InputParams = Self::parse_args(args)?;
3877                self.input(Parameters(p)).await
3878            }
3879            "window" => {
3880                let p: WindowParams = Self::parse_args(args)?;
3881                self.window(Parameters(p)).await
3882            }
3883            "storage" => {
3884                let p: StorageParams = Self::parse_args(args)?;
3885                self.storage(Parameters(p)).await
3886            }
3887            "navigate" => {
3888                let p: NavigateParams = Self::parse_args(args)?;
3889                self.navigate(Parameters(p)).await
3890            }
3891            "recording" => {
3892                let p: RecordingParams = Self::parse_args(args)?;
3893                self.recording(Parameters(p)).await
3894            }
3895            "inspect" => {
3896                let p: InspectParams = Self::parse_args(args)?;
3897                self.inspect(Parameters(p)).await
3898            }
3899            "css" => {
3900                let p: CssParams = Self::parse_args(args)?;
3901                self.css(Parameters(p)).await
3902            }
3903            "route" => {
3904                let p: RouteParams = Self::parse_args(args)?;
3905                self.route(Parameters(p)).await
3906            }
3907            "trace" => {
3908                let p: TraceParams = Self::parse_args(args)?;
3909                self.trace(Parameters(p)).await
3910            }
3911            "animation" => {
3912                let p: AnimationParams = Self::parse_args(args)?;
3913                self.animation(Parameters(p)).await
3914            }
3915            "logs" => {
3916                let p: LogsParams = Self::parse_args(args)?;
3917                self.logs(Parameters(p)).await
3918            }
3919            "introspect" => {
3920                let p: IntrospectParams = Self::parse_args(args)?;
3921                self.introspect(Parameters(p)).await
3922            }
3923            "fault" => {
3924                let p: FaultParams = Self::parse_args(args)?;
3925                self.fault(Parameters(p)).await
3926            }
3927            "explain" => {
3928                let p: ExplainParams = Self::parse_args(args)?;
3929                self.explain(Parameters(p)).await
3930            }
3931            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3932        };
3933
3934        let elapsed = start.elapsed();
3935        tracing::debug!(
3936            tool = %name,
3937            elapsed_ms = elapsed.as_millis() as u64,
3938            "REST tool invocation completed"
3939        );
3940
3941        if self.state.privacy.redaction_enabled {
3942            Ok(Self::redact_result(result, &self.state.privacy))
3943        } else {
3944            Ok(result)
3945        }
3946    }
3947
3948    fn parse_args<T: serde::de::DeserializeOwned>(
3949        args: serde_json::Value,
3950    ) -> Result<T, rest::ToolCallError> {
3951        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3952    }
3953
3954    fn redact_result(
3955        mut result: CallToolResult,
3956        privacy: &crate::privacy::PrivacyConfig,
3957    ) -> CallToolResult {
3958        for item in &mut result.content {
3959            if let RawContent::Text(ref mut tc) = item.raw {
3960                tc.text = privacy.redact_output(&tc.text);
3961            }
3962        }
3963        result
3964    }
3965
3966    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3967        match dir.unwrap_or(AppDir::Data) {
3968            AppDir::Data => self.bridge.app_data_dir(),
3969            AppDir::Config => self.bridge.app_config_dir(),
3970            AppDir::Log => self.bridge.app_log_dir(),
3971            AppDir::LocalData => self.bridge.app_local_data_dir(),
3972        }
3973    }
3974
3975    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
3976    ///
3977    /// Rejects absolute paths and any component that is `..` BEFORE the path is
3978    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
3979    /// `canonicalize`, which errors on non-existent paths — so a traversal
3980    /// attempt against a missing target would otherwise be reported as
3981    /// "not found" (an info-leak oracle) rather than as traversal.
3982    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
3983        use std::path::Component;
3984        if sub.is_absolute() {
3985            return Err("path traversal not allowed: absolute paths are rejected".to_string());
3986        }
3987        for component in sub.components() {
3988            match component {
3989                Component::ParentDir => {
3990                    return Err("path traversal not allowed: '..' is rejected".to_string());
3991                }
3992                Component::Prefix(_) | Component::RootDir => {
3993                    return Err(
3994                        "path traversal not allowed: absolute paths are rejected".to_string()
3995                    );
3996                }
3997                Component::CurDir | Component::Normal(_) => {}
3998            }
3999        }
4000        Ok(())
4001    }
4002
4003    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
4004        let canon_base = std::fs::canonicalize(base)
4005            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
4006        let canon_target = std::fs::canonicalize(target)
4007            .map_err(|e| format!("cannot resolve target path: {e}"))?;
4008        if !canon_target.starts_with(&canon_base) {
4009            return Err("path traversal not allowed".to_string());
4010        }
4011        Ok(())
4012    }
4013
4014    #[cfg(feature = "sqlite")]
4015    fn resolve_existing_db_path(
4016        roots: &[std::path::PathBuf],
4017        requested: &str,
4018    ) -> Result<std::path::PathBuf, String> {
4019        let candidate = std::path::Path::new(requested);
4020        if candidate.is_absolute() {
4021            if !candidate.exists() {
4022                return Err(format!("database not found: {requested}"));
4023            }
4024            if roots
4025                .iter()
4026                .any(|root| Self::safe_within(root, candidate).is_ok())
4027            {
4028                return Ok(candidate.to_path_buf());
4029            }
4030            return Err(format!(
4031                "absolute path '{requested}' is not within an allowed directory; \
4032                 register its parent via VictauriBuilder::db_search_paths"
4033            ));
4034        }
4035
4036        Self::lexical_safe(candidate)?;
4037        for root in roots {
4038            let resolved = root.join(candidate);
4039            if resolved.exists() {
4040                Self::safe_within(root, &resolved)?;
4041                // Open the CANONICAL validated path, not the lexical join, so the DB is opened
4042                // at exactly the path containment approved (closes the trivial validate-lexical
4043                // vs open-lexical TOCTOU; a same-privilege local symlink swap between
4044                // canonicalize and open is the unavoidable residual, documented in security.md).
4045                let canonical = std::fs::canonicalize(&resolved)
4046                    .map_err(|e| format!("cannot resolve database path: {e}"))?;
4047                return Ok(canonical);
4048            }
4049        }
4050
4051        let roots = roots
4052            .iter()
4053            .map(|root| root.display().to_string())
4054            .collect::<Vec<_>>()
4055            .join(", ");
4056        Err(format!(
4057            "database not found: {requested} (searched: {roots})"
4058        ))
4059    }
4060
4061    #[cfg(feature = "sqlite")]
4062    fn quote_sqlite_identifier(identifier: &str) -> String {
4063        format!("\"{}\"", identifier.replace('"', "\"\""))
4064    }
4065
4066    fn list_dir_recursive(
4067        dir: &std::path::Path,
4068        base: &std::path::Path,
4069        depth: u32,
4070        max_depth: u32,
4071        pattern: Option<&str>,
4072        entries: &mut Vec<serde_json::Value>,
4073    ) {
4074        if entries.len() >= MAX_DIR_ENTRIES {
4075            return;
4076        }
4077        let Ok(read_dir) = std::fs::read_dir(dir) else {
4078            return;
4079        };
4080        for entry in read_dir.flatten() {
4081            if entries.len() >= MAX_DIR_ENTRIES {
4082                return;
4083            }
4084            let path = entry.path();
4085            if path.is_symlink() {
4086                continue;
4087            }
4088            // `is_symlink` does not cover every redirecting filesystem object
4089            // (notably Windows directory junctions/reparse points). Canonical
4090            // containment is the actual boundary before metadata or recursion.
4091            if Self::safe_within(base, &path).is_err() {
4092                continue;
4093            }
4094            let name = entry.file_name().to_string_lossy().into_owned();
4095            let relative = path
4096                .strip_prefix(base)
4097                .unwrap_or(&path)
4098                .to_string_lossy()
4099                .into_owned();
4100
4101            if let Some(pat) = pattern
4102                && !Self::matches_glob(&name, pat)
4103                && !path.is_dir()
4104            {
4105                continue;
4106            }
4107
4108            let is_dir = path.is_dir();
4109            let meta = std::fs::metadata(&path).ok();
4110
4111            entries.push(serde_json::json!({
4112                "name": name,
4113                "path": relative,
4114                "is_dir": is_dir,
4115                "size": meta.as_ref().map(std::fs::Metadata::len),
4116                "modified": meta.as_ref()
4117                    .and_then(|m| m.modified().ok())
4118                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
4119                        .unwrap_or_default().as_secs()),
4120            }));
4121
4122            if is_dir && depth < max_depth {
4123                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
4124            }
4125        }
4126    }
4127
4128    fn matches_glob(name: &str, pattern: &str) -> bool {
4129        if pattern == "*" {
4130            return true;
4131        }
4132        if let Some(suffix) = pattern.strip_prefix("*.") {
4133            return name.ends_with(&format!(".{suffix}"));
4134        }
4135        if let Some(prefix) = pattern.strip_suffix("*") {
4136            return name.starts_with(prefix);
4137        }
4138        name == pattern
4139    }
4140
4141    /// Probe every window's JS bridge and report which are introspectable. A
4142    /// visible window that fails to respond almost always lacks the
4143    /// `victauri:default` capability — Tauri's permission ACL silently blocks
4144    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
4145    /// turns that silent dead-end into an actionable, up-front diagnosis.
4146    async fn window_introspectability(&self) -> CallToolResult {
4147        let labels = self.bridge.list_window_labels();
4148        let states = self.bridge.get_window_states(None);
4149        let mut report = Vec::with_capacity(labels.len());
4150        let mut blind = 0usize;
4151        for label in &labels {
4152            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
4153            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
4154            if !introspectable {
4155                blind += 1;
4156            }
4157            let note = if introspectable {
4158                "ok — Victauri JS bridge is responding".to_string()
4159            } else if visible == Some(true) {
4160                format!(
4161                    "NOT introspectable although the window is visible — almost certainly missing \
4162                     the Victauri capability. Add \"victauri:default\" to the capability file \
4163                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
4164                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
4165                )
4166            } else {
4167                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
4168                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
4169                    .to_string()
4170            };
4171            report.push(serde_json::json!({
4172                "label": label,
4173                "visible": visible,
4174                "introspectable": introspectable,
4175                "note": note,
4176            }));
4177        }
4178        let hint = if blind > 0 {
4179            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
4180             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
4181             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
4182             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
4183             not just for the main window. (Note: probing a blind window takes ~2s each.)"
4184        } else {
4185            "All windows are introspectable."
4186        };
4187        json_result(&serde_json::json!({
4188            "windows": report,
4189            "introspectable_count": labels.len().saturating_sub(blind),
4190            "blind_count": blind,
4191            "hint": hint,
4192        }))
4193    }
4194
4195    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
4196        match self.eval_with_return(code, webview_label).await {
4197            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
4198            Err(e) => tool_error(e),
4199        }
4200    }
4201
4202    async fn eval_with_return(
4203        &self,
4204        code: &str,
4205        webview_label: Option<&str>,
4206    ) -> Result<String, String> {
4207        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
4208            .await
4209    }
4210
4211    /// Atomically reserve a pending-eval slot under a SINGLE lock: reject if the map is
4212    /// already at the concurrency ceiling, otherwise insert. This makes `MAX_PENDING_EVALS`
4213    /// a TRUE hard ceiling — a separate check-then-insert races (concurrent callers all pass
4214    /// a stale check, then each inserts, blowing past the cap). On a saturated map it also
4215    /// fails fast (before any eval is injected) with the real "too many concurrent" cause
4216    /// rather than letting a probe burn its full timeout.
4217    async fn reserve_pending(
4218        &self,
4219        id: &str,
4220        tx: tokio::sync::oneshot::Sender<String>,
4221    ) -> Result<(), String> {
4222        let mut pending = self.state.pending_evals.lock().await;
4223        if pending.len() >= MAX_PENDING_EVALS {
4224            return Err(format!(
4225                "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4226            ));
4227        }
4228        pending.insert(id.to_string(), tx);
4229        Ok(())
4230    }
4231
4232    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
4233        let id = uuid::Uuid::new_v4().to_string();
4234        let (tx, rx) = tokio::sync::oneshot::channel();
4235        self.reserve_pending(&id, tx).await?;
4236        let id_js = js_string(&id);
4237        let probe = format!(
4238            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
4239        );
4240        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
4241            self.state.pending_evals.lock().await.remove(&id);
4242            return Err(format!("eval injection failed: {e}"));
4243        }
4244        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
4245            Ok(())
4246        } else {
4247            self.state.pending_evals.lock().await.remove(&id);
4248            let label = webview_label.unwrap_or("default");
4249            Err(format!(
4250                "bridge not responding on window '{label}' — the window may be hidden, \
4251                 missing the victauri capability, or the JS bridge is not loaded (e.g. the page \
4252                 failed to load: a dev-server connection-refused or blank error page has no JS \
4253                 bridge — check the window with the `screenshot` tool, which works regardless)"
4254            ))
4255        }
4256    }
4257
4258    async fn eval_with_return_timeout(
4259        &self,
4260        code: &str,
4261        webview_label: Option<&str>,
4262        timeout: std::time::Duration,
4263    ) -> Result<String, String> {
4264        // The hard concurrency ceiling is enforced atomically at every reservation
4265        // (`reserve_pending`, used by both the probe and the real eval below) — NOT with a
4266        // separate early check, which races: concurrent callers would all pass a stale
4267        // `len()` read before any of them inserts. The probe is the first reservation, so a
4268        // saturated map is rejected fast (before any eval is injected) with the real "too
4269        // many concurrent" cause.
4270
4271        // Wait for the JS bridge ready signal (sent on bridge init) before
4272        // attempting evals.  For explicitly targeted windows the probe
4273        // mechanism is still used because the ready signal only proves that
4274        // *some* webview's bridge loaded — not necessarily the targeted one.
4275        if !self
4276            .state
4277            .bridge_ready
4278            .load(std::sync::atomic::Ordering::Acquire)
4279        {
4280            let notified = self.state.bridge_notify.notified();
4281            if !self
4282                .state
4283                .bridge_ready
4284                .load(std::sync::atomic::Ordering::Acquire)
4285            {
4286                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
4287            }
4288        }
4289
4290        // Reserved sentinel key for the default (unlabeled) window — cannot
4291        // collide with a real label.
4292        let label_key =
4293            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
4294
4295        // Liveness probe before EVERY eval — on the DEFAULT window as well as
4296        // labeled ones. The probe is a tiny round-trip that returns in ~ms on a
4297        // healthy bridge and fails fast (~2s) on a dead/hung/reloading one, turning
4298        // a full-timeout hang (e.g. 30s) into an immediate, clear "bridge not
4299        // responding" error. This was the #1 live-4DA friction: a webview that
4300        // reloads mid-session (HMR) made the very next tool call hang the full
4301        // timeout, and the DEFAULT window — the most common target — was never
4302        // probed at all. Probing every call (not once-cached) is what guarantees
4303        // *zero* 30s hangs even across repeated reloads; the healthy-path cost is a
4304        // single sub-millisecond localhost round-trip, negligible against the value
4305        // of never stalling an agent into a CDP fallback. (A saturated pending-eval
4306        // map is already rejected above, before this probe.)
4307        let prev_timed_out = self.timed_out_labels.lock().await.remove(&label_key);
4308        if let Err(e) = self.probe_bridge(webview_label).await {
4309            return Err(if prev_timed_out {
4310                format!(
4311                    "{e} (a previous eval on this window also timed out — the webview \
4312                     likely reloaded or the app stopped responding)"
4313                )
4314            } else {
4315                e
4316            });
4317        }
4318
4319        let id = uuid::Uuid::new_v4().to_string();
4320        let (tx, rx) = tokio::sync::oneshot::channel();
4321        self.reserve_pending(&id, tx).await?;
4322
4323        // Auto-prepend `return` so bare expressions produce a value — but ONLY
4324        // for single expressions. Multi-statement blocks (or code containing an
4325        // explicit `return`) are used as-is. Prepending `return` to a statement
4326        // block like `foo(); return bar()` would parse as `return foo();` and
4327        // silently discard everything after the first statement (issue: core
4328        // primitive returned wrong/undefined values for "do X, then return Y").
4329        let code = if should_prepend_return(code) {
4330            format!("return {}", code.trim())
4331        } else {
4332            code.trim().to_string()
4333        };
4334
4335        let id_js = js_string(&id);
4336
4337        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
4338        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
4339        // the script below; if it has a parse error the WHOLE script fails to parse and the
4340        // try/catch never runs, so the callback never fires. We cannot wrap the code in
4341        // `new Function`/`AsyncFunction` to surface the SyntaxError, because dynamic code
4342        // generation is gated by the same `unsafe-eval` CSP that blocks `eval()` — which is
4343        // exactly why the bridge uses an inline async-IIFE in the first place. Instead an
4344        // independent watchdog (which always parses) reports a parse error quickly: the
4345        // user-code script sets a `started` flag at its very top, so a script that fails to
4346        // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started`
4347        // immediately and is left to run to the real timeout — the watchdog only fires when
4348        // the code never began executing.
4349        let watchdog = format!(
4350            r"
4351            (function () {{
4352                window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4353                var s = (window.__VIC_EVAL__[{id_js}] =
4354                    window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4355                setTimeout(function () {{
4356                    if (s.started || s.done) return;
4357                    s.done = true;
4358                    try {{
4359                        window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4360                            id: {id_js},
4361                            result: JSON.stringify({{ __victauri_err: 'code did not begin executing within {PARSE_WATCHDOG_MS}ms — this almost always means a syntax/parse error in the submitted code (or the page main thread was blocked)' }})
4362                        }});
4363                    }} catch (e) {{}}
4364                    delete window.__VIC_EVAL__[{id_js}];
4365                }}, {PARSE_WATCHDOG_MS});
4366            }})();
4367            "
4368        );
4369
4370        let inject = format!(
4371            r"
4372            (async () => {{
4373                var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4374                if (__s) __s.started = true;
4375                try {{
4376                    const __result = await (async () => {{ {code} }})();
4377                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4378                    const __type = __result === undefined ? 'undefined'
4379                        : __result === null ? 'null' : 'value';
4380                    const __val = __type === 'undefined' ? null
4381                        : __type === 'null' ? null : __result;
4382                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4383                        id: {id_js},
4384                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4385                    }});
4386                }} catch (e) {{
4387                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4388                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4389                        id: {id_js},
4390                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4391                    }});
4392                }}
4393            }})();
4394            "
4395        );
4396
4397        // Inject the watchdog first so it is armed before the user code runs. Order is not
4398        // critical (the user-code script no-ops the watchdog state if it ran first), but
4399        // arming first minimises the window.
4400        if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4401            self.state.pending_evals.lock().await.remove(&id);
4402            return Err(format!("eval injection failed: {e}"));
4403        }
4404        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4405            self.state.pending_evals.lock().await.remove(&id);
4406            return Err(format!("eval injection failed: {e}"));
4407        }
4408
4409        match tokio::time::timeout(timeout, rx).await {
4410            Ok(Ok(raw)) => {
4411                self.check_bridge_version_once();
4412                if raw.len() > MAX_EVAL_RESULT_LEN {
4413                    return Err(format!(
4414                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4415                        raw.len()
4416                    ));
4417                }
4418                unwrap_eval_envelope(raw)
4419            }
4420            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4421            Err(_) => {
4422                self.state.pending_evals.lock().await.remove(&id);
4423                // Mark this window so the NEXT eval does a fast liveness probe —
4424                // if the bridge is gone (reloaded/crashed) the next call fails in
4425                // ~2s instead of blocking the full timeout again.
4426                self.timed_out_labels.lock().await.insert(label_key.clone());
4427                Err(format!(
4428                    "eval timed out after {}s — the code began executing but never resolved. \
4429                     (A syntax/parse error would have failed fast via the parse watchdog, so \
4430                     this is NOT a parse error.) Common causes: an unresolved promise, an \
4431                     infinite loop, an `await` on something that never settles, or the webview \
4432                     reloaded / the app stopped responding mid-eval. If the app may have \
4433                     navigated or crashed, retry (the next call fails fast if the bridge is \
4434                     gone).",
4435                    timeout.as_secs()
4436                ))
4437            }
4438        }
4439    }
4440
4441    #[cfg(feature = "sqlite")]
4442    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4443        // Roots: configured db_search_paths first, then app directories.
4444        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4445        for d in [
4446            self.bridge.app_data_dir(),
4447            self.bridge.app_local_data_dir(),
4448            self.bridge.app_config_dir(),
4449        ]
4450        .into_iter()
4451        .flatten()
4452        {
4453            roots.push(d);
4454        }
4455
4456        let path = if let Some(p) = db_path {
4457            Self::resolve_existing_db_path(&roots, p)?
4458        } else {
4459            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
4460            // OS app dirs that hold WebView internals); WebView/engine internal stores are
4461            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
4462            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4463                roots.clone()
4464            } else {
4465                self.state.db_search_paths.clone()
4466            };
4467            crate::database::select_app_database(&select_dirs)?
4468        };
4469        let path_str = path
4470            .to_str()
4471            .ok_or_else(|| "invalid path encoding".to_string())?
4472            .to_string();
4473
4474        tokio::task::spawn_blocking(move || {
4475            let conn = rusqlite::Connection::open_with_flags(
4476                &path_str,
4477                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4478            )
4479            .map_err(|e| format!("cannot open database: {e}"))?;
4480            conn.set_limit(
4481                rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH,
4482                MAX_DB_HEALTH_CELL_BYTES,
4483            );
4484            let started = std::time::Instant::now();
4485            let timed_out = Arc::new(AtomicBool::new(false));
4486            let timeout_marker = Arc::clone(&timed_out);
4487            conn.progress_handler(
4488                DB_HEALTH_PROGRESS_OPS,
4489                Some(move || {
4490                    let expired = started.elapsed() >= DB_HEALTH_TIMEOUT;
4491                    if expired {
4492                        timeout_marker.store(true, Ordering::Relaxed);
4493                    }
4494                    expired
4495                }),
4496            );
4497            // Hard wall-clock backstop for single long ops (e.g. integrity_check / per-table
4498            // count(*) on a huge DB) that the opcode-sampling progress handler under-counts.
4499            let _interrupt = crate::database::InterruptGuard::arm(&conn, DB_HEALTH_TIMEOUT);
4500
4501            let journal_mode: String = conn
4502                .pragma_query_value(None, "journal_mode", |r| r.get(0))
4503                .unwrap_or_else(|_| "unknown".to_string());
4504
4505            let page_count: i64 = conn
4506                .pragma_query_value(None, "page_count", |r| r.get(0))
4507                .unwrap_or(0);
4508
4509            let page_size: i64 = conn
4510                .pragma_query_value(None, "page_size", |r| r.get(0))
4511                .unwrap_or(0);
4512
4513            let freelist_count: i64 = conn
4514                .pragma_query_value(None, "freelist_count", |r| r.get(0))
4515                .unwrap_or(0);
4516
4517            let wal_checkpoint: &str = if journal_mode == "wal" {
4518                "not run (read-only diagnostics)"
4519            } else {
4520                "n/a (not WAL mode)"
4521            };
4522
4523            let integrity: String = conn
4524                .pragma_query_value(None, "quick_check", |r| r.get(0))
4525                .unwrap_or_else(|_| "failed".to_string());
4526
4527            let db_size_bytes = page_count * page_size;
4528            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4529
4530            let mut tables = Vec::new();
4531            let mut table_bytes = 0usize;
4532            let mut tables_truncated = false;
4533            if let Ok(mut stmt) =
4534                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4535                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4536            {
4537                for name in rows.flatten() {
4538                    if tables.len() >= MAX_DB_HEALTH_TABLES
4539                        || table_bytes.saturating_add(name.len()) > MAX_DB_HEALTH_TABLE_BYTES
4540                    {
4541                        tables_truncated = true;
4542                        break;
4543                    }
4544                    table_bytes = table_bytes.saturating_add(name.len());
4545                    let identifier = Self::quote_sqlite_identifier(&name);
4546                    let count: i64 = conn
4547                        .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |r| {
4548                            r.get(0)
4549                        })
4550                        .unwrap_or(0);
4551                    tables.push(serde_json::json!({
4552                        "name": name,
4553                        "row_count": count,
4554                    }));
4555                }
4556            }
4557            if timed_out.load(Ordering::Relaxed) {
4558                return Err(format!(
4559                    "database diagnostics timed out after {} ms",
4560                    DB_HEALTH_TIMEOUT.as_millis()
4561                ));
4562            }
4563
4564            Ok(serde_json::json!({
4565                "database": path_str,
4566                "journal_mode": journal_mode,
4567                "page_count": page_count,
4568                "page_size": page_size,
4569                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4570                "freelist_count": freelist_count,
4571                "wal_checkpoint": wal_checkpoint,
4572                "integrity_check": integrity,
4573                "tables": tables,
4574                "tables_truncated": tables_truncated,
4575            }))
4576        })
4577        .await
4578        .map_err(|e| format!("db health task failed: {e}"))?
4579    }
4580
4581    fn check_bridge_version_once(&self) {
4582        if self.bridge_checked.swap(true, Ordering::Relaxed) {
4583            return;
4584        }
4585        let handler = self.clone();
4586        tokio::spawn(async move {
4587            match handler
4588                .eval_with_return_timeout(
4589                    "window.__VICTAURI__?.version",
4590                    None,
4591                    std::time::Duration::from_secs(5),
4592                )
4593                .await
4594            {
4595                Ok(v) => {
4596                    let v = v.trim_matches('"');
4597                    if v == BRIDGE_VERSION {
4598                        tracing::debug!("Bridge version verified: {v}");
4599                    } else {
4600                        tracing::warn!(
4601                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4602                        );
4603                    }
4604                }
4605                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4606            }
4607        });
4608    }
4609}
4610
4611const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4612It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4613(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4614(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4615\n\nBACKEND tools (direct Rust access, no webview needed): \
4616'app_info' (app config, directory paths, discovered databases, process info), \
4617'list_app_dir' (browse app data/config/log directories), \
4618'read_app_file' (read files from app directories), \
4619'query_db' (read-only SQLite queries with auto-discovery). \
4620\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4621'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4622capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4623Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4624capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4625task tracking, and automatic Tauri event bus monitoring. \
4626'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4627drops, and response corruption into Tauri commands at the Rust layer. \
4628'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4629activity across IPC + DOM + console + network + window events into a coherent narrative. \
4630\n\nWEBVIEW tools: \
4631'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4632'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4633'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4634\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4635\n\nCOMPOUND tools with an 'action' parameter: \
4636'window' (get_state, list, manage, resize, move_to, set_title), \
4637'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4638set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4639get_events, events_between, get_replay, export, import, replay), \
4640'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4641\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4642async backend work to true completion), assert_semantic, resolve_command, \
4643app_state (app-defined backend state probes), \
4644get_memory_stats, get_plugin_info, get_diagnostics.";
4645
4646impl ServerHandler for VictauriMcpHandler {
4647    fn get_info(&self) -> ServerInfo {
4648        // NOTE: we advertise `resources` (read) but NOT `resources.subscribe`. A real
4649        // server-initiated `notifications/resources/updated` push was never implemented
4650        // (subscribe/unsubscribe only record intent in memory; nothing emits updates), and
4651        // the default stateless transport has no SSE channel to push over anyway. Advertising
4652        // a subscribe capability we cannot honour misleads clients — read resources on demand.
4653        ServerInfo::new(
4654            ServerCapabilities::builder()
4655                .enable_tools()
4656                .enable_resources()
4657                .build(),
4658        )
4659        .with_instructions(SERVER_INSTRUCTIONS)
4660    }
4661
4662    async fn list_tools(
4663        &self,
4664        _request: Option<PaginatedRequestParams>,
4665        _context: RequestContext<RoleServer>,
4666    ) -> Result<ListToolsResult, ErrorData> {
4667        let all_tools = Self::tool_router().list_all();
4668        let filtered: Vec<Tool> = all_tools
4669            .into_iter()
4670            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4671            .collect();
4672        Ok(ListToolsResult {
4673            tools: filtered,
4674            ..Default::default()
4675        })
4676    }
4677
4678    async fn call_tool(
4679        &self,
4680        request: CallToolRequestParams,
4681        context: RequestContext<RoleServer>,
4682    ) -> Result<CallToolResult, ErrorData> {
4683        let tool_name: String = request.name.as_ref().to_owned();
4684        // Centralized authorization: gate on the canonical `tool.action` capability
4685        // resolved from the call arguments, matching the REST path in `execute_tool`.
4686        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
4687        let capability = authz::canonical_capability(&tool_name, &args_value);
4688        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
4689            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
4690            return Ok(tool_disabled(&capability));
4691        }
4692        self.state
4693            .tool_invocations
4694            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4695        let start = std::time::Instant::now();
4696        tracing::debug!(tool = %tool_name, "tool invocation started");
4697        let ctx = ToolCallContext::new(self, request, context);
4698        let result = Self::tool_router().call(ctx).await;
4699        let elapsed = start.elapsed();
4700        tracing::debug!(
4701            tool = %tool_name,
4702            elapsed_ms = elapsed.as_millis() as u64,
4703            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4704            "tool invocation completed"
4705        );
4706
4707        // Centralized output redaction: apply to all text content so no
4708        // individual tool can accidentally leak secrets.
4709        if self.state.privacy.redaction_enabled {
4710            result.map(|mut r| {
4711                for item in &mut r.content {
4712                    if let RawContent::Text(ref mut tc) = item.raw {
4713                        tc.text = self.state.privacy.redact_output(&tc.text);
4714                    }
4715                }
4716                r
4717            })
4718        } else {
4719            result
4720        }
4721    }
4722
4723    fn get_tool(&self, name: &str) -> Option<Tool> {
4724        if !self.state.privacy.is_tool_enabled(name) {
4725            return None;
4726        }
4727        Self::tool_router().get(name).cloned()
4728    }
4729
4730    async fn list_resources(
4731        &self,
4732        _request: Option<PaginatedRequestParams>,
4733        _context: RequestContext<RoleServer>,
4734    ) -> Result<ListResourcesResult, ErrorData> {
4735        Ok(ListResourcesResult {
4736            resources: vec![
4737                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4738                    .with_description(
4739                        "Live IPC call log — all commands invoked between frontend and backend",
4740                    )
4741                    .with_mime_type("application/json")
4742                    .no_annotation(),
4743                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4744                    .with_description(
4745                        "Current state of all Tauri windows — position, size, visibility, focus",
4746                    )
4747                    .with_mime_type("application/json")
4748                    .no_annotation(),
4749                RawResource::new(RESOURCE_URI_STATE, "state")
4750                    .with_description(
4751                        "Victauri plugin state — event count, registered commands, memory stats",
4752                    )
4753                    .with_mime_type("application/json")
4754                    .no_annotation(),
4755            ],
4756            ..Default::default()
4757        })
4758    }
4759
4760    async fn read_resource(
4761        &self,
4762        request: ReadResourceRequestParams,
4763        _context: RequestContext<RoleServer>,
4764    ) -> Result<ReadResourceResult, ErrorData> {
4765        let uri = &request.uri;
4766        // Resources bypass the tool dispatcher, so they must apply the same privacy
4767        // gate themselves (audit B1): a strict profile that blocks log/window reads
4768        // as tools must not be able to read the same data via a resource.
4769        if let Some(cap) = resource_required_capability(uri.as_str())
4770            && !self.state.privacy.is_tool_enabled(cap)
4771        {
4772            return Err(ErrorData::invalid_request(
4773                format!("resource {uri} is not permitted by the current privacy configuration"),
4774                None,
4775            ));
4776        }
4777        let json = match uri.as_str() {
4778            RESOURCE_URI_IPC_LOG => {
4779                // Use the body-free, capped projection — NOT the full body-carrying
4780                // getIpcLog(). On a busy app the full log blows the eval result cap, the
4781                // eval fails, and we silently fall back to the Rust event_log (which is
4782                // itself default-window-drained) — serving a subset that looks complete.
4783                // trimmed_log_js bounds entries + truncates oversized fields so the
4784                // resource stays correct under load. (Matches the `logs ipc` tool.)
4785                let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", DEFAULT_LOG_LIMIT);
4786                if let Ok(json) = self.eval_with_return(&code, None).await {
4787                    json
4788                } else {
4789                    let calls = self.state.event_log.ipc_calls();
4790                    serde_json::to_string_pretty(&calls)
4791                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4792                }
4793            }
4794            RESOURCE_URI_WINDOWS => {
4795                let states = self.bridge.get_window_states(None);
4796                serde_json::to_string_pretty(&states)
4797                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4798            }
4799            RESOURCE_URI_STATE => {
4800                let state_json = serde_json::json!({
4801                    "events_captured": self.state.event_log.len(),
4802                    "commands_registered": self.state.registry.count(),
4803                    "memory": crate::memory::current_stats(),
4804                    "port": self.state.port.load(Ordering::Relaxed),
4805                });
4806                serde_json::to_string_pretty(&state_json)
4807                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4808            }
4809            _ => {
4810                return Err(ErrorData::resource_not_found(
4811                    format!("unknown resource: {uri}"),
4812                    None,
4813                ));
4814            }
4815        };
4816
4817        let json = if self.state.privacy.redaction_enabled {
4818            self.state.privacy.redact_output(&json)
4819        } else {
4820            json
4821        };
4822
4823        Ok(ReadResourceResult::new(vec![ResourceContents::text(
4824            json, uri,
4825        )]))
4826    }
4827
4828    async fn subscribe(
4829        &self,
4830        request: SubscribeRequestParams,
4831        _context: RequestContext<RoleServer>,
4832    ) -> Result<(), ErrorData> {
4833        let uri = &request.uri;
4834        // Same privacy gate as read_resource (audit B1) — don't let a blocked
4835        // resource be subscribed to for push updates.
4836        if let Some(cap) = resource_required_capability(uri.as_str())
4837            && !self.state.privacy.is_tool_enabled(cap)
4838        {
4839            return Err(ErrorData::invalid_request(
4840                format!("resource {uri} is not permitted by the current privacy configuration"),
4841                None,
4842            ));
4843        }
4844        match uri.as_str() {
4845            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4846                self.subscriptions.lock().await.insert(uri.clone());
4847                tracing::info!("Client subscribed to resource: {uri}");
4848                Ok(())
4849            }
4850            _ => Err(ErrorData::resource_not_found(
4851                format!("unknown resource: {uri}"),
4852                None,
4853            )),
4854        }
4855    }
4856
4857    async fn unsubscribe(
4858        &self,
4859        request: UnsubscribeRequestParams,
4860        _context: RequestContext<RoleServer>,
4861    ) -> Result<(), ErrorData> {
4862        self.subscriptions.lock().await.remove(&request.uri);
4863        tracing::info!("Client unsubscribed from resource: {}", request.uri);
4864        Ok(())
4865    }
4866}
4867
4868/// Build a JS expression that takes an array of log entries (`source_expr`),
4869/// keeps at most `limit` of the most recent, and truncates any per-entry field
4870/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
4871/// the eval size cap on busy apps where individual entries carry large bodies.
4872///
4873/// The returned code is a complete `return (...)` statement.
4874fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4875    let mb = MAX_LOG_FIELD_BYTES;
4876    format!(
4877        r"return (function() {{
4878            var MB = {mb};
4879            function trimField(v) {{
4880                if (typeof v === 'string') {{
4881                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4882                }}
4883                if (v && typeof v === 'object') {{
4884                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4885                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4886                }}
4887                return v;
4888            }}
4889            function trimEntry(e) {{
4890                if (e == null || typeof e !== 'object') return e;
4891                var out = Array.isArray(e) ? [] : {{}};
4892                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4893                return out;
4894            }}
4895            var arr = {source_expr} || [];
4896            if (arr.length > {limit}) arr = arr.slice(-{limit});
4897            return arr.map(trimEntry);
4898        }})()"
4899    )
4900}
4901
4902/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
4903/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
4904/// value/error string returned to callers.
4905///
4906/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
4907/// disabled — an unbounded recursive parse of a pathologically deep result
4908/// overflows the worker thread stack and crashes the host). When the parse
4909/// fails because the value is too deeply nested, the envelope is stripped by
4910/// string slicing (no recursion) so the actual value is still returned rather
4911/// than leaking the raw envelope string.
4912fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4913    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4914        if let Some(err) = envelope.get("__victauri_err") {
4915            return Err(format!(
4916                "JavaScript error: {}",
4917                err.as_str().unwrap_or("unknown error")
4918            ));
4919        }
4920        if envelope.get("__victauri_ok").is_some() {
4921            let js_type = envelope
4922                .get("__victauri_type")
4923                .and_then(|t| t.as_str())
4924                .unwrap_or("value");
4925            return match js_type {
4926                "undefined" => Ok("undefined".to_string()),
4927                "null" => Ok("null".to_string()),
4928                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4929                    .unwrap_or_else(|_| "null".to_string())),
4930            };
4931        }
4932    }
4933    // Fallback for results too deeply nested for the recursion-limited parser.
4934    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4935        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4936    {
4937        return Ok(after[..idx].to_string());
4938    }
4939    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4940        let msg = after.trim_end_matches('}').trim_matches('"');
4941        return Err(format!("JavaScript error: {msg}"));
4942    }
4943    Ok(raw)
4944}
4945
4946/// Statement keywords where a leading `return` would be a syntax error.
4947const STMT_STARTS: &[&str] = &[
4948    "return ",
4949    "return;",
4950    "return\n",
4951    "return\t",
4952    "if ",
4953    "if(",
4954    "for ",
4955    "for(",
4956    "while ",
4957    "while(",
4958    "switch ",
4959    "switch(",
4960    "try ",
4961    "try{",
4962    "const ",
4963    "let ",
4964    "var ",
4965    "function ",
4966    "function(",
4967    "function*",
4968    "class ",
4969    "throw ",
4970    "do ",
4971    "do{",
4972    "{",
4973    "async function",
4974    "debugger",
4975];
4976
4977/// String/template/comment scan state for [`should_prepend_return`].
4978#[derive(PartialEq, Clone, Copy)]
4979enum ScanState {
4980    Code,
4981    SingleQuote,
4982    DoubleQuote,
4983    Template,
4984}
4985
4986/// Decide whether to wrap `code` with a leading `return`.
4987///
4988/// Only a single bare expression should get `return` prepended. Code that is a
4989/// multi-statement block, contains an explicit top-level `return`, or starts
4990/// with a statement keyword is used as-is — prepending `return` to such code
4991/// would execute only the first statement and silently discard the rest.
4992///
4993/// The scan is string/template/comment-aware and only treats a `;` or an
4994/// explicit `return` token as significant when it occurs at bracket depth 0
4995/// outside of any string, template literal, or comment.
4996fn should_prepend_return(code: &str) -> bool {
4997    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4998
4999    let code = code.trim();
5000    if code.is_empty() {
5001        return false;
5002    }
5003
5004    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
5005        return false;
5006    }
5007
5008    let bytes = code.as_bytes();
5009    let mut i = 0;
5010    let mut depth: i32 = 0;
5011    let mut state = ScanState::Code;
5012
5013    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
5014    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
5015    let is_return_token = |i: usize| -> bool {
5016        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
5017        prev_ok
5018            && code[i..].starts_with("return")
5019            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
5020    };
5021
5022    while i < bytes.len() {
5023        let c = bytes[i];
5024        match state {
5025            Code => match c {
5026                b'\'' => state = SingleQuote,
5027                b'"' => state = DoubleQuote,
5028                b'`' => state = Template,
5029                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
5030                    while i < bytes.len() && bytes[i] != b'\n' {
5031                        i += 1;
5032                    }
5033                    continue;
5034                }
5035                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
5036                    i += 2;
5037                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
5038                        i += 1;
5039                    }
5040                    i += 2;
5041                    continue;
5042                }
5043                b'(' | b'[' | b'{' => depth += 1,
5044                b')' | b']' | b'}' => depth -= 1,
5045                // A top-level `;` with more code after it == multi-statement.
5046                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
5047                // An explicit top-level `return` token means the code already returns.
5048                b'r' if depth <= 0 && is_return_token(i) => return false,
5049                _ => {}
5050            },
5051            SingleQuote => {
5052                if c == b'\\' {
5053                    i += 1;
5054                } else if c == b'\'' {
5055                    state = Code;
5056                }
5057            }
5058            DoubleQuote => {
5059                if c == b'\\' {
5060                    i += 1;
5061                } else if c == b'"' {
5062                    state = Code;
5063                }
5064            }
5065            Template => {
5066                if c == b'\\' {
5067                    i += 1;
5068                } else if c == b'`' {
5069                    state = Code;
5070                }
5071            }
5072        }
5073        i += 1;
5074    }
5075
5076    true
5077}
5078
5079#[cfg(test)]
5080mod prop_tests {
5081    //! Property-based tests for the eval auto-return heuristic — the code that
5082    //! caused the worst bug in the system (silent corruption of multi-statement
5083    //! eval) and has bitten twice. These generate many JS-ish snippets and
5084    //! assert the invariants that keep eval correct.
5085    use super::should_prepend_return;
5086    use proptest::prelude::*;
5087
5088    /// A small set of non-keyword identifier-ish expressions.
5089    fn ident() -> impl Strategy<Value = String> {
5090        prop_oneof![
5091            Just("a".to_string()),
5092            Just("x".to_string()),
5093            Just("foo".to_string()),
5094            Just("window.x".to_string()),
5095            Just("document.title".to_string()),
5096            Just("obj.prop".to_string()),
5097            Just("arr[0]".to_string()),
5098            Just("localStorage".to_string()),
5099        ]
5100    }
5101
5102    /// A single bare expression: never starts with a statement keyword, has no
5103    /// top-level `;`, and contains no `return`.
5104    fn bare_expr() -> impl Strategy<Value = String> {
5105        prop_oneof![
5106            ident(),
5107            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
5108            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
5109            ident().prop_map(|a| format!("{a}.length")),
5110            any::<u16>().prop_map(|n| n.to_string()),
5111        ]
5112    }
5113
5114    proptest! {
5115        /// Must never panic or hang on ANY input — including malformed code,
5116        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
5117        #[test]
5118        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
5119            let _ = should_prepend_return(&s);
5120        }
5121
5122        /// A single bare expression is safe to wrap with `return` → true.
5123        #[test]
5124        fn bare_expressions_are_prepended(e in bare_expr()) {
5125            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
5126        }
5127
5128        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
5129        /// (else `return <expr>;` runs and the rest is silently discarded).
5130        #[test]
5131        fn semicolon_multistatement_with_return_never_prepended(
5132            setup in bare_expr(), ret in bare_expr()
5133        ) {
5134            let code = format!("{setup}; return {ret}");
5135            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
5136        }
5137
5138        /// Newline-separated (ASI) explicit return must also be left as-is.
5139        #[test]
5140        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
5141            let code = format!("{pre}\nreturn {ret}");
5142            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
5143        }
5144
5145        /// `;` or the word `return` INSIDE a string literal must not trigger a
5146        /// false multi-statement split — a bare string is one expression.
5147        #[test]
5148        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
5149            // `inner` never contains a quote, so the literal is well-formed.
5150            let code = format!("'do;not;split return {inner}'");
5151            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
5152        }
5153    }
5154}
5155
5156#[cfg(test)]
5157mod tests {
5158    use super::*;
5159
5160    #[cfg(feature = "sqlite")]
5161    #[test]
5162    fn database_path_resolution_rejects_lexical_escape() {
5163        let dir = tempfile::tempdir().unwrap();
5164        let root = dir.path().join("allowed");
5165        std::fs::create_dir(&root).unwrap();
5166        std::fs::File::create(dir.path().join("outside.db")).unwrap();
5167
5168        let err =
5169            VictauriMcpHandler::resolve_existing_db_path(&[root], "../outside.db").unwrap_err();
5170        assert!(err.contains("path traversal"), "unexpected error: {err}");
5171    }
5172
5173    #[cfg(feature = "sqlite")]
5174    #[test]
5175    fn database_path_resolution_accepts_contained_nested_file() {
5176        let dir = tempfile::tempdir().unwrap();
5177        let root = dir.path().join("allowed");
5178        let nested = root.join("nested");
5179        std::fs::create_dir_all(&nested).unwrap();
5180        let db = nested.join("app.db");
5181        std::fs::File::create(&db).unwrap();
5182
5183        let resolved =
5184            VictauriMcpHandler::resolve_existing_db_path(&[root], "nested/app.db").unwrap();
5185        // Resolution returns the CANONICAL validated path (opened == validated, closing the
5186        // lexical-vs-canonical TOCTOU), so compare against the canonical form of the target.
5187        assert_eq!(resolved, std::fs::canonicalize(&db).unwrap());
5188    }
5189
5190    #[cfg(all(feature = "sqlite", unix))]
5191    #[test]
5192    fn database_path_resolution_rejects_symlink_escape() {
5193        use std::os::unix::fs::symlink;
5194
5195        let dir = tempfile::tempdir().unwrap();
5196        let root = dir.path().join("allowed");
5197        std::fs::create_dir(&root).unwrap();
5198        let outside = dir.path().join("outside.db");
5199        std::fs::File::create(&outside).unwrap();
5200        symlink(&outside, root.join("linked.db")).unwrap();
5201
5202        let err = VictauriMcpHandler::resolve_existing_db_path(&[root], "linked.db").unwrap_err();
5203        assert!(err.contains("path traversal"), "unexpected error: {err}");
5204    }
5205
5206    #[cfg(feature = "sqlite")]
5207    #[test]
5208    fn sqlite_identifier_quoting_handles_hostile_table_names() {
5209        let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
5210        let conn = rusqlite::Connection::open(file.path()).unwrap();
5211        let name = "odd\"] table";
5212        let identifier = VictauriMcpHandler::quote_sqlite_identifier(name);
5213        conn.execute_batch(&format!(
5214            "CREATE TABLE {identifier} (id INTEGER); INSERT INTO {identifier} VALUES (1);"
5215        ))
5216        .unwrap();
5217        let count: i64 = conn
5218            .query_row(&format!("SELECT count(*) FROM {identifier}"), [], |row| {
5219                row.get(0)
5220            })
5221            .unwrap();
5222        assert_eq!(count, 1);
5223    }
5224
5225    #[test]
5226    fn env_filter_drops_secrets_keeps_safe() {
5227        // Safe, non-secret vars pass.
5228        assert!(is_safe_env_key("HOME"));
5229        assert!(is_safe_env_key("LANG"));
5230        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
5231        assert!(is_safe_env_key("VICTAURI_PORT"));
5232        // Secret-looking vars are dropped even under a safe prefix (audit #5).
5233        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
5234        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
5235        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
5236        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
5237        // Unknown prefixes are dropped regardless.
5238        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
5239        assert!(!is_safe_env_key("RANDOM_VAR"));
5240        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
5241        // app-custom TAURI_ secrets are dropped even without a denylist hit.
5242        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
5243        // Adversarial leaks closed (audit #5 follow-up): connection strings,
5244        // passphrases, PATs, JWTs, etc. under an allowed prefix.
5245        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
5246        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
5247        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
5248        assert!(!is_safe_env_key("VICTAURI_JWT"));
5249        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
5250    }
5251
5252    #[test]
5253    fn prepend_return_bare_expressions() {
5254        assert!(should_prepend_return("document.title"));
5255        assert!(should_prepend_return("5 + 5"));
5256        assert!(should_prepend_return("\"justexpr\""));
5257        assert!(should_prepend_return("await fetch('/x')"));
5258        assert!(should_prepend_return(
5259            "document.querySelectorAll('a').length"
5260        ));
5261        assert!(should_prepend_return("x ? a : b"));
5262        // Single trailing semicolon on a bare expression is still an expression.
5263        assert!(should_prepend_return("document.title;"));
5264        // Semicolons inside strings must not be treated as boundaries.
5265        assert!(should_prepend_return("'a;b;c'"));
5266        assert!(should_prepend_return("\"x;y\".length"));
5267        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
5268        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
5269    }
5270
5271    #[test]
5272    fn no_prepend_for_statement_blocks() {
5273        // The original silent-corruption cases.
5274        assert!(!should_prepend_return(
5275            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
5276        ));
5277        assert!(!should_prepend_return(
5278            "window.scrollTo(0,50); return window.scrollY"
5279        ));
5280        assert!(!should_prepend_return("console.log('x'); return 123"));
5281        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
5282        // Explicit return without a preceding semicolon (newline-separated).
5283        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
5284    }
5285
5286    #[test]
5287    fn no_prepend_for_statement_keywords() {
5288        assert!(!should_prepend_return("return 42"));
5289        assert!(!should_prepend_return("const x = 1; return x"));
5290        assert!(!should_prepend_return("let y = 2"));
5291        assert!(!should_prepend_return("var z = 3"));
5292        assert!(!should_prepend_return("if (x) { return 1 }"));
5293        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
5294        assert!(!should_prepend_return("throw new Error('x')"));
5295        assert!(!should_prepend_return("function f(){}"));
5296        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
5297    }
5298
5299    #[test]
5300    fn empty_code_no_prepend() {
5301        assert!(!should_prepend_return(""));
5302        assert!(!should_prepend_return("   "));
5303    }
5304
5305    #[test]
5306    fn envelope_unwrap_value() {
5307        assert_eq!(
5308            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
5309            Ok("\"4DA\"".to_string())
5310        );
5311        assert_eq!(
5312            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
5313            Ok("42".to_string())
5314        );
5315    }
5316
5317    #[test]
5318    fn envelope_unwrap_undefined_null() {
5319        assert_eq!(
5320            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
5321            Ok("undefined".to_string())
5322        );
5323        assert_eq!(
5324            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
5325            Ok("null".to_string())
5326        );
5327    }
5328
5329    #[test]
5330    fn envelope_unwrap_error() {
5331        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
5332        assert!(r.unwrap_err().contains("boom"));
5333    }
5334
5335    #[test]
5336    fn envelope_unwrap_deeply_nested_does_not_leak() {
5337        // Build an envelope whose value is nested far deeper than serde_json's
5338        // default recursion limit (128). The full parse fails, so the slice
5339        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
5340        let mut value = String::from("0");
5341        for _ in 0..300 {
5342            value = format!("{{\"n\":{value}}}");
5343        }
5344        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
5345        let out = unwrap_eval_envelope(raw).unwrap();
5346        assert!(
5347            out.starts_with(r#"{"n":"#),
5348            "deep value should be unwrapped, got: {}",
5349            &out[..out.len().min(40)]
5350        );
5351        assert!(
5352            !out.contains("__victauri_ok"),
5353            "envelope must not leak into the result"
5354        );
5355    }
5356
5357    #[test]
5358    fn js_string_simple() {
5359        assert_eq!(js_string("hello"), "\"hello\"");
5360    }
5361
5362    #[test]
5363    fn js_string_single_quotes() {
5364        let result = js_string("it's a test");
5365        assert!(result.contains("it's a test"));
5366    }
5367
5368    #[test]
5369    fn js_string_double_quotes() {
5370        let result = js_string(r#"say "hello""#);
5371        assert!(result.contains(r#"\""#));
5372    }
5373
5374    #[test]
5375    fn js_string_backslashes() {
5376        let result = js_string(r"path\to\file");
5377        assert!(result.contains(r"\\"));
5378    }
5379
5380    #[test]
5381    fn js_string_newlines_and_tabs() {
5382        let result = js_string("line1\nline2\ttab");
5383        assert!(result.contains(r"\n"));
5384        assert!(result.contains(r"\t"));
5385        assert!(!result.contains('\n'));
5386    }
5387
5388    #[test]
5389    fn js_string_null_bytes() {
5390        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
5391        let result = js_string(&input);
5392        // serde_json escapes null bytes as
5393        assert!(result.contains("\\u0000"));
5394        assert!(!result.contains('\0'));
5395    }
5396
5397    #[test]
5398    fn js_string_template_literal_injection() {
5399        let result = js_string("`${alert(1)}`");
5400        // Should not contain unescaped backticks that could break template literals
5401        // serde_json wraps in double quotes, so backticks are safe
5402        assert!(result.starts_with('"'));
5403        assert!(result.ends_with('"'));
5404    }
5405
5406    #[test]
5407    fn js_string_unicode_separators() {
5408        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
5409        // JSON strings per RFC 8259, and serde_json passes them through literally.
5410        // Since js_string is used inside JS double-quoted strings (not template
5411        // literals), they are safe in modern JS engines (ES2019+).
5412        let result = js_string("a\u{2028}b\u{2029}c");
5413        // Verify the string is valid JSON that round-trips correctly
5414        let decoded: String = serde_json::from_str(&result).unwrap();
5415        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
5416    }
5417
5418    #[test]
5419    fn js_string_empty() {
5420        assert_eq!(js_string(""), "\"\"");
5421    }
5422
5423    #[test]
5424    fn js_string_html_script_close() {
5425        // </script> in a JS string inside HTML could break out of script tags
5426        let result = js_string("</script><img onerror=alert(1)>");
5427        assert!(result.starts_with('"'));
5428        // The string is JSON-encoded; verify it round-trips safely
5429        let decoded: String = serde_json::from_str(&result).unwrap();
5430        assert_eq!(decoded, "</script><img onerror=alert(1)>");
5431    }
5432
5433    #[test]
5434    fn js_string_very_long() {
5435        let long = "a".repeat(100_000);
5436        let result = js_string(&long);
5437        assert!(result.len() >= 100_002); // quotes + content
5438    }
5439
5440    // ── URL validation tests ────────────────────────────────────────────────
5441
5442    #[test]
5443    fn url_allows_http() {
5444        assert!(validate_url("http://example.com", false).is_ok());
5445    }
5446
5447    #[test]
5448    fn url_allows_https() {
5449        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5450    }
5451
5452    #[test]
5453    fn url_allows_http_localhost() {
5454        assert!(validate_url("http://localhost:3000", false).is_ok());
5455    }
5456
5457    #[test]
5458    fn url_blocks_file_by_default() {
5459        let err = validate_url("file:///etc/passwd", false).unwrap_err();
5460        assert!(err.contains("file"), "error should mention the file scheme");
5461    }
5462
5463    #[test]
5464    fn url_allows_file_when_opted_in() {
5465        assert!(validate_url("file:///tmp/test.html", true).is_ok());
5466    }
5467
5468    #[test]
5469    fn url_blocks_javascript() {
5470        assert!(validate_url("javascript:alert(1)", false).is_err());
5471    }
5472
5473    #[test]
5474    fn url_blocks_javascript_case_insensitive() {
5475        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5476    }
5477
5478    #[test]
5479    fn url_blocks_data_scheme() {
5480        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5481    }
5482
5483    #[test]
5484    fn url_blocks_vbscript() {
5485        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5486    }
5487
5488    #[test]
5489    fn url_rejects_invalid() {
5490        assert!(validate_url("not a url at all", false).is_err());
5491    }
5492
5493    #[test]
5494    fn url_strips_control_chars() {
5495        // Control characters should be stripped, leaving a valid URL
5496        let input = format!("http://example{}com", '\0');
5497        assert!(validate_url(&input, false).is_ok());
5498    }
5499
5500    // ── CSS color sanitization tests ───────────────────────────────────────
5501
5502    #[test]
5503    fn css_color_valid_hex() {
5504        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5505        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5506        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5507    }
5508
5509    #[test]
5510    fn css_color_valid_rgb() {
5511        assert_eq!(
5512            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5513            "rgb(255, 0, 0)"
5514        );
5515        assert_eq!(
5516            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5517            "rgba(0, 0, 0, 0.5)"
5518        );
5519    }
5520
5521    #[test]
5522    fn css_color_valid_named() {
5523        assert_eq!(sanitize_css_color("red").unwrap(), "red");
5524        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5525    }
5526
5527    #[test]
5528    fn css_color_valid_hsl() {
5529        assert_eq!(
5530            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5531            "hsl(120, 50%, 50%)"
5532        );
5533    }
5534
5535    #[test]
5536    fn css_color_rejects_too_long() {
5537        let long = "a".repeat(101);
5538        assert!(sanitize_css_color(&long).is_err());
5539    }
5540
5541    #[test]
5542    fn css_color_rejects_backslash_escapes() {
5543        assert!(sanitize_css_color(r"red\00").is_err());
5544        assert!(sanitize_css_color(r"\72\65\64").is_err());
5545    }
5546
5547    #[test]
5548    fn css_color_rejects_url_injection() {
5549        assert!(sanitize_css_color("url(http://evil.com)").is_err());
5550        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5551    }
5552
5553    #[test]
5554    fn css_color_rejects_expression_injection() {
5555        assert!(sanitize_css_color("expression(alert(1))").is_err());
5556        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5557    }
5558
5559    #[test]
5560    fn css_color_rejects_import() {
5561        assert!(sanitize_css_color("@import url(evil.css)").is_err());
5562    }
5563
5564    #[test]
5565    fn css_color_rejects_semicolons_and_braces() {
5566        assert!(sanitize_css_color("red; background: url(evil)").is_err());
5567        assert!(sanitize_css_color("red} body { color: blue").is_err());
5568    }
5569
5570    #[test]
5571    fn css_color_rejects_special_chars() {
5572        assert!(sanitize_css_color("red<script>").is_err());
5573        assert!(sanitize_css_color("red\"onload=alert").is_err());
5574        assert!(sanitize_css_color("red'onclick=alert").is_err());
5575    }
5576
5577    #[test]
5578    fn css_color_trims_whitespace() {
5579        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
5580    }
5581
5582    #[test]
5583    fn css_color_empty_string() {
5584        assert_eq!(sanitize_css_color("").unwrap(), "");
5585    }
5586}
5587
5588/// Dispatch-level authorization tests.
5589///
5590/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
5591/// string matrix) to prove that blocked tools/actions actually return
5592/// `tool_disabled` and never reach their handler. This is the negative security
5593/// suite the audit required (Gate #5): the prior tests validated
5594/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
5595/// pass undetected.
5596#[cfg(test)]
5597mod authz_dispatch_tests {
5598    use super::*;
5599    use crate::bridge::WebviewBridge;
5600    use crate::privacy::PrivacyConfig;
5601    use std::collections::{HashMap, HashSet};
5602    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
5603
5604    /// A bridge whose eval always fails immediately, so an *allowed* action that
5605    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
5606    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
5607    struct RejectingBridge;
5608
5609    impl WebviewBridge for RejectingBridge {
5610        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
5611            Err("eval rejected in authz dispatch test".to_string())
5612        }
5613        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
5614            Vec::new()
5615        }
5616        fn list_window_labels(&self) -> Vec<String> {
5617            Vec::new()
5618        }
5619        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
5620            Err("no handle".to_string())
5621        }
5622        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
5623            Err("no window".to_string())
5624        }
5625        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5626            Ok(())
5627        }
5628        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5629            Ok(())
5630        }
5631        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5632            Ok(())
5633        }
5634    }
5635
5636    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5637        Arc::new(VictauriState {
5638            event_log: EventLog::new(1000),
5639            registry: CommandRegistry::new(),
5640            port: std::sync::atomic::AtomicU16::new(0),
5641            pending_evals: Arc::new(Mutex::new(HashMap::new())),
5642            recorder: EventRecorder::new(1000),
5643            privacy,
5644            eval_timeout: std::time::Duration::from_millis(100),
5645            shutdown_tx: tokio::sync::watch::channel(false).0,
5646            started_at: std::time::Instant::now(),
5647            tool_invocations: std::sync::atomic::AtomicU64::new(0),
5648            allow_file_navigation: false,
5649            command_timings: crate::introspection::CommandTimings::new(),
5650            fault_registry: crate::introspection::FaultRegistry::new(),
5651            contract_store: crate::introspection::ContractStore::new(),
5652            startup_timeline: crate::introspection::StartupTimeline::new(),
5653            event_bus: crate::introspection::EventBusMonitor::default(),
5654            task_tracker: crate::introspection::TaskTracker::new(),
5655            bridge_ready: std::sync::atomic::AtomicBool::new(true),
5656            bridge_notify: tokio::sync::Notify::new(),
5657            db_search_paths: Vec::new(),
5658            screencast: Arc::new(crate::screencast::Screencast::default()),
5659            probes: crate::introspection::AppStateProbes::default(),
5660        })
5661    }
5662
5663    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
5664        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
5665    }
5666
5667    /// True iff the result is a privacy/authorization block (vs any other error).
5668    fn is_privacy_blocked(r: &CallToolResult) -> bool {
5669        r.is_error == Some(true)
5670            && r.content.iter().any(|c| {
5671                matches!(&c.raw, RawContent::Text(t)
5672                    if t.text.contains("disabled by privacy configuration"))
5673            })
5674    }
5675
5676    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5677        match h.execute_tool(tool, args).await {
5678            Ok(r) => r,
5679            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5680        }
5681    }
5682
5683    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
5684
5685    #[tokio::test]
5686    async fn observe_blocks_mutations_and_eval_through_dispatch() {
5687        let h = handler(crate::privacy::observe_privacy_config());
5688        let blocked: &[(&str, serde_json::Value)] = &[
5689            ("eval_js", serde_json::json!({"code": "1"})),
5690            (
5691                "wait_for",
5692                serde_json::json!({"condition": "expression", "value": "true"}),
5693            ),
5694            ("screenshot", serde_json::json!({})),
5695            ("invoke_command", serde_json::json!({"command": "greet"})),
5696            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5697            (
5698                "assert_semantic",
5699                serde_json::json!({"expression": "1", "condition": "truthy"}),
5700            ),
5701            (
5702                "interact",
5703                serde_json::json!({"action": "click", "ref_id": "e1"}),
5704            ),
5705            (
5706                "input",
5707                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5708            ),
5709            (
5710                "storage",
5711                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5712            ),
5713            (
5714                "storage",
5715                serde_json::json!({"action": "delete", "key": "k"}),
5716            ),
5717            (
5718                "window",
5719                serde_json::json!({"action": "manage", "manage_action": "close"}),
5720            ),
5721            (
5722                "window",
5723                serde_json::json!({"action": "set_title", "title": "x"}),
5724            ),
5725            (
5726                "navigate",
5727                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5728            ),
5729            (
5730                "css",
5731                serde_json::json!({"action": "inject", "css": "body{}"}),
5732            ),
5733            ("route", serde_json::json!({"action": "clear_all"})),
5734            ("recording", serde_json::json!({"action": "start"})),
5735            ("recording", serde_json::json!({"action": "replay"})),
5736            ("logs", serde_json::json!({"action": "clear"})),
5737            (
5738                "fault",
5739                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
5740            ),
5741            (
5742                "introspect",
5743                serde_json::json!({"action": "command_timings"}),
5744            ),
5745        ];
5746        for (tool, args) in blocked {
5747            let r = call(&h, tool, args.clone()).await;
5748            assert!(
5749                is_privacy_blocked(&r),
5750                "Observe must block {tool} {args} at dispatch, got: {:?}",
5751                r.content
5752            );
5753        }
5754    }
5755
5756    #[tokio::test]
5757    async fn observe_allows_read_only_through_dispatch() {
5758        let h = handler(crate::privacy::observe_privacy_config());
5759        // These reads must NOT be privacy-blocked (they may fail for other reasons
5760        // against the rejecting bridge, but never with a privacy block).
5761        let allowed: &[(&str, serde_json::Value)] = &[
5762            ("get_registry", serde_json::json!({})),
5763            ("get_memory_stats", serde_json::json!({})),
5764            ("window", serde_json::json!({"action": "list"})),
5765            ("logs", serde_json::json!({"action": "ipc"})),
5766            (
5767                "inspect",
5768                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
5769            ),
5770        ];
5771        for (tool, args) in allowed {
5772            let r = call(&h, tool, args.clone()).await;
5773            assert!(
5774                !is_privacy_blocked(&r),
5775                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
5776            );
5777        }
5778    }
5779
5780    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
5781
5782    #[tokio::test]
5783    async fn test_profile_dispatch_boundaries() {
5784        let h = handler(crate::privacy::test_privacy_config());
5785        // Allowed in Test:
5786        for (tool, args) in [
5787            (
5788                "interact",
5789                serde_json::json!({"action": "click", "ref_id": "e1"}),
5790            ),
5791            (
5792                "input",
5793                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5794            ),
5795            (
5796                "storage",
5797                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5798            ),
5799            ("navigate", serde_json::json!({"action": "go_back"})),
5800            ("recording", serde_json::json!({"action": "start"})),
5801            ("logs", serde_json::json!({"action": "clear"})),
5802        ] {
5803            let r = call(&h, tool, args.clone()).await;
5804            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
5805        }
5806        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
5807        for (tool, args) in [
5808            ("eval_js", serde_json::json!({"code": "1"})),
5809            (
5810                "wait_for",
5811                serde_json::json!({"condition": "expression", "value": "true"}),
5812            ),
5813            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5814            (
5815                "navigate",
5816                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5817            ),
5818            ("recording", serde_json::json!({"action": "replay"})),
5819            (
5820                "route",
5821                serde_json::json!({"action": "add", "pattern": "x"}),
5822            ),
5823            ("css", serde_json::json!({"action": "inject", "css": "x"})),
5824            (
5825                "window",
5826                serde_json::json!({"action": "set_title", "title": "x"}),
5827            ),
5828        ] {
5829            let r = call(&h, tool, args.clone()).await;
5830            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
5831        }
5832    }
5833
5834    // ── disabled_tools: bare-name disable covers all of a compound tool's
5835    //    actions, and per-action disable is honored even when the handler
5836    //    historically did not check it (the route.clear bypass). ──────────────
5837
5838    #[tokio::test]
5839    async fn disabling_bare_compound_tool_blocks_all_actions() {
5840        let cfg = PrivacyConfig {
5841            disabled_tools: HashSet::from(["recording".to_string()]),
5842            ..Default::default()
5843        }; // FullControl with the whole `recording` tool disabled
5844        let h = handler(cfg);
5845        for action in ["start", "stop", "replay", "import", "export"] {
5846            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
5847            assert!(
5848                is_privacy_blocked(&r),
5849                "disabling bare `recording` must block recording.{action}"
5850            );
5851        }
5852    }
5853
5854    #[tokio::test]
5855    async fn disabling_specific_action_is_honored_at_dispatch() {
5856        // The historical bypass: `route.clear`'s handler had no per-action check,
5857        // so a `disabled_tools` entry for it was silently ignored. The central
5858        // gate now enforces it.
5859        let cfg = PrivacyConfig {
5860            disabled_tools: HashSet::from([
5861                "route.clear".to_string(),
5862                "route.clear_all".to_string(),
5863            ]),
5864            ..Default::default()
5865        }; // FullControl: everything else allowed
5866        let h = handler(cfg);
5867
5868        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
5869        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
5870        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
5871        assert!(
5872            is_privacy_blocked(&blocked_all),
5873            "route.clear_all must be blocked"
5874        );
5875
5876        // A sibling action the operator did NOT disable is still reachable.
5877        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
5878        assert!(
5879            !is_privacy_blocked(&allowed),
5880            "route.list must remain allowed"
5881        );
5882    }
5883
5884    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
5885    // are covered with side-effect detection (a bridge that records actual invokes)
5886    // in the `command_policy_dispatch_tests` module below — that proves the blocked
5887    // command never reaches the bridge, not merely that an error string is returned.
5888
5889    #[tokio::test]
5890    async fn full_control_allows_everything_at_dispatch() {
5891        let h = handler(PrivacyConfig::default());
5892        for (tool, args) in [
5893            ("recording", serde_json::json!({"action": "replay"})),
5894            ("route", serde_json::json!({"action": "clear_all"})),
5895            ("eval_js", serde_json::json!({"code": "1"})),
5896            ("fault", serde_json::json!({"action": "list"})),
5897        ] {
5898            let r = call(&h, tool, args.clone()).await;
5899            assert!(
5900                !is_privacy_blocked(&r),
5901                "FullControl must allow {tool} {args}"
5902            );
5903        }
5904    }
5905}
5906
5907/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
5908///
5909/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
5910/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
5911/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
5912///
5913/// These tests instead drive the REAL dispatcher with a bridge that records every script
5914/// handed to `eval_webview`, and assert the dangerous **side effect** — the
5915/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
5916/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
5917/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
5918/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
5919/// invoked (so a blanket-block can't make the negative test pass vacuously).
5920#[cfg(test)]
5921mod command_policy_dispatch_tests {
5922    use super::*;
5923    use crate::bridge::WebviewBridge;
5924    use crate::privacy::PrivacyConfig;
5925    use serde_json::json;
5926    use std::collections::{HashMap, HashSet};
5927    use std::sync::Mutex as StdMutex;
5928    use victauri_core::{
5929        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
5930        RecordedSession, WindowState,
5931    };
5932
5933    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
5934    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
5935    /// command is observably *attempted* without hanging on a callback that never arrives.
5936    ///
5937    /// When constructed via [`RecordingBridge::answering`] it also resolves the pre-eval
5938    /// liveness probe, simulating a healthy webview so an ALLOWED command's invoke actually
5939    /// reaches the bridge. Default-constructed bridges leave the probe unanswered — which is
5940    /// fine for negative tests, since a blocked command is rejected at the privacy gate
5941    /// *before* any eval (and thus never probes).
5942    #[derive(Clone, Default)]
5943    struct RecordingBridge {
5944        scripts: Arc<StdMutex<Vec<String>>>,
5945        pending_evals: Option<crate::PendingCallbacks>,
5946    }
5947
5948    /// Extract the 36-char eval id from a probe script of the form `…id:"<uuid>"…`.
5949    fn extract_probe_id(script: &str) -> Option<String> {
5950        let start = script.find("id:\"")? + 4;
5951        script.get(start..start + 36).map(str::to_string)
5952    }
5953
5954    impl RecordingBridge {
5955        /// A recording bridge that answers the liveness probe with the state's pending-evals
5956        /// map, so a permitted command's eval proceeds past the probe and is observably
5957        /// injected.
5958        fn answering(pending_evals: crate::PendingCallbacks) -> Self {
5959            Self {
5960                scripts: Arc::default(),
5961                pending_evals: Some(pending_evals),
5962            }
5963        }
5964
5965        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
5966        fn invoked(&self, command: &str) -> bool {
5967            let needle = format!("invoke({}", js_string(command));
5968            self.scripts
5969                .lock()
5970                .unwrap_or_else(std::sync::PoisonError::into_inner)
5971                .iter()
5972                .any(|s| s.contains(&needle))
5973        }
5974    }
5975
5976    impl WebviewBridge for RecordingBridge {
5977        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
5978            self.scripts
5979                .lock()
5980                .unwrap_or_else(std::sync::PoisonError::into_inner)
5981                .push(script.to_string());
5982            // If wired with a pending-evals map, answer the pre-eval liveness probe
5983            // (simulating a healthy webview) so the real eval proceeds past it. The
5984            // real eval is still left unanswered, so it times out fast at the 100ms
5985            // test `eval_timeout` — we only care WHICH scripts reached the bridge,
5986            // never the eval's return value.
5987            if let Some(pending) = &self.pending_evals
5988                && script.contains("probe_ok")
5989                && let Some(id) = extract_probe_id(script)
5990            {
5991                let pending = pending.clone();
5992                std::thread::spawn(move || {
5993                    let mut map = pending.blocking_lock();
5994                    if let Some(tx) = map.remove(&id) {
5995                        let _ = tx.send("\"probe_ok\"".to_string());
5996                    }
5997                });
5998            }
5999            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
6000            // user-code script (it bails on the first Err).
6001            Ok(())
6002        }
6003        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
6004            Vec::new()
6005        }
6006        fn list_window_labels(&self) -> Vec<String> {
6007            Vec::new()
6008        }
6009        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
6010            Err("no handle".to_string())
6011        }
6012        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6013            Err("no window".to_string())
6014        }
6015        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6016            Ok(())
6017        }
6018        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6019            Ok(())
6020        }
6021        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6022            Ok(())
6023        }
6024    }
6025
6026    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
6027        Arc::new(VictauriState {
6028            event_log: EventLog::new(1000),
6029            registry: CommandRegistry::new(),
6030            port: std::sync::atomic::AtomicU16::new(0),
6031            pending_evals: Arc::new(Mutex::new(HashMap::new())),
6032            recorder: EventRecorder::new(1000),
6033            privacy,
6034            eval_timeout: std::time::Duration::from_millis(100),
6035            shutdown_tx: tokio::sync::watch::channel(false).0,
6036            started_at: std::time::Instant::now(),
6037            tool_invocations: std::sync::atomic::AtomicU64::new(0),
6038            allow_file_navigation: false,
6039            command_timings: crate::introspection::CommandTimings::new(),
6040            fault_registry: crate::introspection::FaultRegistry::new(),
6041            contract_store: crate::introspection::ContractStore::new(),
6042            startup_timeline: crate::introspection::StartupTimeline::new(),
6043            event_bus: crate::introspection::EventBusMonitor::default(),
6044            task_tracker: crate::introspection::TaskTracker::new(),
6045            bridge_ready: std::sync::atomic::AtomicBool::new(true),
6046            bridge_notify: tokio::sync::Notify::new(),
6047            db_search_paths: Vec::new(),
6048            screencast: Arc::new(crate::screencast::Screencast::default()),
6049            probes: crate::introspection::AppStateProbes::default(),
6050        })
6051    }
6052
6053    // FullControl, except the named commands are blocklisted — exactly the scenario
6054    // the audit flagged: an operator who trusts `command_blocklist` to stop a
6055    // dangerous command.
6056    fn blocking(cmds: &[&str]) -> PrivacyConfig {
6057        PrivacyConfig {
6058            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
6059            ..Default::default()
6060        }
6061    }
6062
6063    fn ipc_event(command: &str) -> AppEvent {
6064        AppEvent::Ipc(IpcCall {
6065            id: format!("c-{command}"),
6066            command: command.to_string(),
6067            timestamp: chrono::Utc::now(),
6068            duration_ms: Some(1),
6069            result: IpcResult::Ok(json!(true)),
6070            arg_size_bytes: 0,
6071            webview_label: "main".to_string(),
6072        })
6073    }
6074
6075    fn result_text(r: &CallToolResult) -> String {
6076        r.content
6077            .iter()
6078            .filter_map(|c| match &c.raw {
6079                RawContent::Text(t) => Some(t.text.clone()),
6080                _ => None,
6081            })
6082            .collect::<Vec<_>>()
6083            .join("\n")
6084    }
6085
6086    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
6087        match h.execute_tool(tool, args).await {
6088            Ok(r) => r,
6089            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
6090        }
6091    }
6092
6093    // ── introspect event_bus output cap (VIC-4) ──────────────────────────────
6094    #[tokio::test]
6095    async fn event_bus_caps_output_to_limit() {
6096        // The full buffers can be tens of thousands of events (megabytes); the action must cap
6097        // output (default 100, newest first) and still report the true total + a truncated flag.
6098        use crate::introspection::CapturedTauriEvent;
6099        let state = state_with(PrivacyConfig::default());
6100        for i in 0..150 {
6101            state.event_bus.push(CapturedTauriEvent {
6102                name: format!("evt-{i}"),
6103                payload: "{}".to_string(),
6104                timestamp: chrono::Utc::now().to_rfc3339(),
6105            });
6106        }
6107        let h = VictauriMcpHandler::new(state, Arc::new(RecordingBridge::default()));
6108
6109        // Default limit (100).
6110        let r = call(&h, "introspect", json!({"action": "event_bus"})).await;
6111        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6112        assert_eq!(
6113            v["tauri_events"]["count"], 150,
6114            "true total must be reported"
6115        );
6116        assert_eq!(v["tauri_events"]["returned"], 100, "default cap is 100");
6117        assert_eq!(v["tauri_events"]["truncated"], true);
6118        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 100);
6119
6120        // Explicit smaller limit (passed via the generic `args` object).
6121        let r = call(
6122            &h,
6123            "introspect",
6124            json!({"action": "event_bus", "args": {"limit": 10}}),
6125        )
6126        .await;
6127        let v: serde_json::Value = serde_json::from_str(&result_text(&r)).unwrap();
6128        assert_eq!(v["tauri_events"]["returned"], 10);
6129        assert_eq!(v["tauri_events"]["events"].as_array().unwrap().len(), 10);
6130    }
6131
6132    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
6133
6134    #[tokio::test]
6135    async fn replay_never_invokes_a_blocklisted_command() {
6136        let bridge = RecordingBridge::default();
6137        let state = state_with(blocking(&["delete_account"]));
6138        state.recorder.start("s1".to_string()).unwrap();
6139        state.recorder.record_event(ipc_event("delete_account"));
6140        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6141
6142        let r = call(&h, "recording", json!({"action": "replay"})).await;
6143
6144        assert!(
6145            !bridge.invoked("delete_account"),
6146            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
6147        );
6148        assert!(
6149            result_text(&r).contains("blocked"),
6150            "replay should report the command as blocked, got: {}",
6151            result_text(&r)
6152        );
6153    }
6154
6155    #[tokio::test]
6156    async fn replay_does_invoke_an_allowed_command() {
6157        // Positive control: proves the negative test isn't vacuous (the path really
6158        // reaches the bridge for a permitted command).
6159        let state = state_with(PrivacyConfig::default());
6160        let bridge = RecordingBridge::answering(state.pending_evals.clone());
6161        state.recorder.start("s1".to_string()).unwrap();
6162        state.recorder.record_event(ipc_event("greet"));
6163        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6164
6165        let _ = call(&h, "recording", json!({"action": "replay"})).await;
6166
6167        assert!(
6168            bridge.invoked("greet"),
6169            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
6170        );
6171    }
6172
6173    #[tokio::test]
6174    async fn imported_session_cannot_invoke_a_blocklisted_command() {
6175        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
6176        // must not become arbitrary command invocation.
6177        let bridge = RecordingBridge::default();
6178        let state = state_with(blocking(&["wipe_database"]));
6179        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6180
6181        let session = RecordedSession {
6182            id: "poisoned".to_string(),
6183            started_at: chrono::Utc::now(),
6184            events: vec![RecordedEvent {
6185                index: 0,
6186                timestamp: chrono::Utc::now(),
6187                event: ipc_event("wipe_database"),
6188            }],
6189            checkpoints: Vec::new(),
6190        };
6191        let session_json = serde_json::to_string(&session).unwrap();
6192
6193        let imp = call(
6194            &h,
6195            "recording",
6196            json!({"action": "import", "session_json": session_json}),
6197        )
6198        .await;
6199        assert_ne!(
6200            imp.is_error,
6201            Some(true),
6202            "import itself should succeed: {}",
6203            result_text(&imp)
6204        );
6205
6206        let r = call(&h, "recording", json!({"action": "replay"})).await;
6207        assert!(
6208            !bridge.invoked("wipe_database"),
6209            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
6210        );
6211        assert!(result_text(&r).contains("blocked"));
6212    }
6213
6214    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
6215
6216    #[tokio::test]
6217    async fn contract_record_never_invokes_a_blocklisted_command() {
6218        let bridge = RecordingBridge::default();
6219        let state = state_with(blocking(&["delete_account"]));
6220        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6221
6222        let r = call(
6223            &h,
6224            "introspect",
6225            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
6226        )
6227        .await;
6228
6229        assert!(
6230            !bridge.invoked("delete_account"),
6231            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
6232        );
6233        assert_eq!(r.is_error, Some(true));
6234        assert!(
6235            result_text(&r).contains("blocked by privacy configuration"),
6236            "got: {}",
6237            result_text(&r)
6238        );
6239    }
6240
6241    #[tokio::test]
6242    async fn contract_record_does_invoke_an_allowed_command() {
6243        let state = state_with(PrivacyConfig::default());
6244        let bridge = RecordingBridge::answering(state.pending_evals.clone());
6245        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6246
6247        let _ = call(
6248            &h,
6249            "introspect",
6250            json!({"action": "contract_record", "command": "get_settings"}),
6251        )
6252        .await;
6253
6254        assert!(
6255            bridge.invoked("get_settings"),
6256            "positive control failed: contract_record did not invoke an allowed command"
6257        );
6258    }
6259
6260    // ── pending-eval concurrency ceiling (audit: TOCTOU race) ────────────────
6261    #[tokio::test]
6262    async fn reserve_pending_is_a_hard_ceiling_under_concurrency() {
6263        // A check-then-insert (lock, read len(), unlock, …, lock, insert) races: many
6264        // concurrent callers all pass a STALE len() check before any inserts, blowing past
6265        // MAX_PENDING_EVALS. `reserve_pending` checks AND inserts under one lock, so the cap
6266        // is a true ceiling. Pre-fill to MAX-5, fire 50 concurrent reservations: EXACTLY 5
6267        // may succeed and the map must NEVER exceed the cap.
6268        let state = state_with(PrivacyConfig::default());
6269        {
6270            let mut p = state.pending_evals.lock().await;
6271            for i in 0..(MAX_PENDING_EVALS - 5) {
6272                let (tx, _rx) = tokio::sync::oneshot::channel();
6273                p.insert(format!("pre-{i}"), tx);
6274            }
6275        }
6276        let h = Arc::new(VictauriMcpHandler::new(
6277            state.clone(),
6278            Arc::new(RecordingBridge::default()),
6279        ));
6280        let mut tasks = Vec::new();
6281        for i in 0..50 {
6282            let h = h.clone();
6283            tasks.push(tokio::spawn(async move {
6284                let (tx, _rx) = tokio::sync::oneshot::channel();
6285                // keep rx alive until the reservation has been decided
6286                let ok = h.reserve_pending(&format!("c-{i}"), tx).await.is_ok();
6287                (ok, _rx)
6288            }));
6289        }
6290        let mut granted = 0;
6291        let mut keep = Vec::new();
6292        for t in tasks {
6293            let (ok, rx) = t.await.unwrap();
6294            if ok {
6295                granted += 1;
6296            }
6297            keep.push(rx); // hold receivers so reserved entries are not dropped/removed
6298        }
6299        let len = state.pending_evals.lock().await.len();
6300        assert!(
6301            len <= MAX_PENDING_EVALS,
6302            "ceiling breached: {len} > {MAX_PENDING_EVALS}"
6303        );
6304        assert_eq!(
6305            granted, 5,
6306            "exactly the 5 free slots should have been reserved, got {granted}"
6307        );
6308        drop(keep);
6309    }
6310
6311    #[tokio::test]
6312    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
6313        // A baseline recorded before the command was blocked must not be re-invoked
6314        // once the operator adds it to the blocklist (audit #30).
6315        let bridge = RecordingBridge::default();
6316        let state = state_with(blocking(&["delete_account"]));
6317        state
6318            .contract_store
6319            .record(crate::introspection::ContractBaseline {
6320                command: "delete_account".to_string(),
6321                args: json!({}),
6322                shape: crate::introspection::JsonShape::from_value(&json!(true)),
6323                sample: "true".to_string(),
6324                recorded_at: chrono_now(),
6325            });
6326        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
6327
6328        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
6329
6330        assert!(
6331            !bridge.invoked("delete_account"),
6332            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
6333        );
6334    }
6335
6336    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
6337
6338    #[test]
6339    fn resource_reads_are_gated_by_their_mirrored_capability() {
6340        // Resources bypass the tool dispatcher, so the read path must apply the same
6341        // gate. Disabling the capability a resource mirrors must block the resource.
6342        let cfg = PrivacyConfig {
6343            disabled_tools: HashSet::from([
6344                "logs.ipc".to_string(),
6345                "window.list".to_string(),
6346                "get_plugin_info".to_string(),
6347            ]),
6348            ..Default::default()
6349        };
6350        for uri in [
6351            RESOURCE_URI_IPC_LOG,
6352            RESOURCE_URI_WINDOWS,
6353            RESOURCE_URI_STATE,
6354        ] {
6355            let cap = resource_required_capability(uri).expect("resource maps to a capability");
6356            assert!(
6357                !cfg.is_tool_enabled(cap),
6358                "disabling capability {cap} must gate resource {uri} (audit B1)"
6359            );
6360        }
6361        // Sanity: with nothing disabled, all three resources read.
6362        let full = PrivacyConfig::default();
6363        for uri in [
6364            RESOURCE_URI_IPC_LOG,
6365            RESOURCE_URI_WINDOWS,
6366            RESOURCE_URI_STATE,
6367        ] {
6368            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap()));
6369        }
6370    }
6371
6372    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
6373
6374    #[tokio::test]
6375    async fn empty_auth_token_collapses_to_no_auth() {
6376        use http_body_util::BodyExt;
6377        use tower::ServiceExt;
6378
6379        for token in [Some(String::new()), Some("   ".to_string())] {
6380            let app = crate::mcp::server::build_app_full(
6381                state_with(PrivacyConfig::default()),
6382                Arc::new(RecordingBridge::default()),
6383                token.clone(),
6384                None,
6385            );
6386            let req = axum::extract::Request::builder()
6387                .uri("/info")
6388                .header("host", "127.0.0.1")
6389                .body(axum::body::Body::empty())
6390                .unwrap();
6391            let resp = app.oneshot(req).await.unwrap();
6392            assert_eq!(
6393                resp.status(),
6394                200,
6395                "/info must be reachable with empty token {token:?} (no auth layer)"
6396            );
6397            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6398            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6399            assert_eq!(
6400                body["auth_required"],
6401                json!(false),
6402                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
6403            );
6404        }
6405    }
6406
6407    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
6408
6409    #[test]
6410    fn is_safe_env_key_drops_secrets_keeps_safe() {
6411        for secret in [
6412            "VICTAURI_AUTH_TOKEN",
6413            "TAURI_SIGNING_PRIVATE_KEY",
6414            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
6415            "CARGO_REGISTRY_TOKEN",
6416            "AWS_SECRET_ACCESS_KEY",
6417            "DATABASE_DSN",
6418            "GH_PAT",
6419        ] {
6420            assert!(
6421                !is_safe_env_key(secret),
6422                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
6423            );
6424        }
6425        for safe in [
6426            "HOME",
6427            "LANG",
6428            "TERM",
6429            "XDG_RUNTIME_DIR",
6430            "TAURI_ENV_PLATFORM",
6431        ] {
6432            assert!(
6433                is_safe_env_key(safe),
6434                "{safe} should be surfaced by app_info"
6435            );
6436        }
6437    }
6438}
6439
6440/// `screenshot` must refuse to "capture" a non-visible window.
6441///
6442/// Live-4DA dogfood (2026-06-16): requesting a hidden window (`label:"briefing"`)
6443/// returned a PNG that was actually the MAIN window's pixels — the OS capture path has
6444/// no live surface for an unmapped window, so it silently yields stale/foreign content.
6445/// The tool now checks visibility first and fails with an actionable message instead.
6446#[cfg(test)]
6447mod screenshot_visibility_tests {
6448    use super::*;
6449    use crate::bridge::WebviewBridge;
6450    use crate::privacy::PrivacyConfig;
6451    use std::collections::HashMap;
6452    use std::sync::Mutex as StdMutex;
6453    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
6454
6455    fn window(label: &str, visible: bool) -> WindowState {
6456        WindowState {
6457            label: label.to_string(),
6458            title: label.to_string(),
6459            url: "http://localhost/".to_string(),
6460            visible,
6461            focused: false,
6462            maximized: false,
6463            minimized: false,
6464            fullscreen: false,
6465            position: (0, 0),
6466            size: (800, 600),
6467        }
6468    }
6469
6470    /// A bridge with a configurable window set that RECORDS the label `get_native_handle`
6471    /// is asked for, then errs. Recording the label lets a test assert WHICH window the
6472    /// screenshot tool resolved to (the audit-P2 case: omitted label must resolve to a
6473    /// VISIBLE window, never hidden "main"); the error lets a test prove the visibility gate
6474    /// fired *before* the OS-handle path was reached.
6475    struct ConfigBridge {
6476        windows: Vec<WindowState>,
6477        handle_label: Arc<StdMutex<Option<Option<String>>>>,
6478    }
6479
6480    impl ConfigBridge {
6481        fn new(windows: Vec<WindowState>) -> Self {
6482            Self {
6483                windows,
6484                handle_label: Arc::new(StdMutex::new(None)),
6485            }
6486        }
6487        /// The label `get_native_handle` was called with, if it was reached.
6488        /// `Some(Some(l))` = called with label `l`; `Some(None)` = called with the default;
6489        /// `None` = never reached (gate short-circuited).
6490        fn requested_handle(&self) -> Option<Option<String>> {
6491            self.handle_label
6492                .lock()
6493                .unwrap_or_else(std::sync::PoisonError::into_inner)
6494                .clone()
6495        }
6496    }
6497
6498    impl WebviewBridge for ConfigBridge {
6499        fn eval_webview(&self, _l: Option<&str>, _s: &str) -> Result<(), String> {
6500            Err("no eval".to_string())
6501        }
6502        fn get_window_states(&self, label: Option<&str>) -> Vec<WindowState> {
6503            match label {
6504                Some(l) => self
6505                    .windows
6506                    .iter()
6507                    .filter(|w| w.label == l)
6508                    .cloned()
6509                    .collect(),
6510                None => self.windows.clone(),
6511            }
6512        }
6513        fn list_window_labels(&self) -> Vec<String> {
6514            self.windows.iter().map(|w| w.label.clone()).collect()
6515        }
6516        fn get_native_handle(&self, l: Option<&str>) -> Result<isize, String> {
6517            *self
6518                .handle_label
6519                .lock()
6520                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(l.map(str::to_string));
6521            Err("native handle path reached".to_string())
6522        }
6523        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
6524            Ok(String::new())
6525        }
6526        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
6527            Ok(())
6528        }
6529        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
6530            Ok(())
6531        }
6532        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
6533            Ok(())
6534        }
6535    }
6536
6537    fn handler_with(bridge: Arc<ConfigBridge>) -> VictauriMcpHandler {
6538        let state = Arc::new(VictauriState {
6539            event_log: EventLog::new(100),
6540            registry: CommandRegistry::new(),
6541            port: std::sync::atomic::AtomicU16::new(0),
6542            pending_evals: Arc::new(Mutex::new(HashMap::new())),
6543            recorder: EventRecorder::new(100),
6544            privacy: PrivacyConfig::default(),
6545            eval_timeout: std::time::Duration::from_millis(100),
6546            shutdown_tx: tokio::sync::watch::channel(false).0,
6547            started_at: std::time::Instant::now(),
6548            tool_invocations: std::sync::atomic::AtomicU64::new(0),
6549            allow_file_navigation: false,
6550            command_timings: crate::introspection::CommandTimings::new(),
6551            fault_registry: crate::introspection::FaultRegistry::new(),
6552            contract_store: crate::introspection::ContractStore::new(),
6553            startup_timeline: crate::introspection::StartupTimeline::new(),
6554            event_bus: crate::introspection::EventBusMonitor::default(),
6555            task_tracker: crate::introspection::TaskTracker::new(),
6556            bridge_ready: std::sync::atomic::AtomicBool::new(true),
6557            bridge_notify: tokio::sync::Notify::new(),
6558            db_search_paths: Vec::new(),
6559            screencast: Arc::new(crate::screencast::Screencast::default()),
6560            probes: crate::introspection::AppStateProbes::default(),
6561        });
6562        VictauriMcpHandler::new(state, bridge)
6563    }
6564
6565    fn error_text(r: &CallToolResult) -> String {
6566        r.content
6567            .iter()
6568            .filter_map(|c| match &c.raw {
6569                RawContent::Text(t) => Some(t.text.clone()),
6570                _ => None,
6571            })
6572            .collect::<Vec<_>>()
6573            .join("\n")
6574    }
6575
6576    #[tokio::test]
6577    async fn hidden_window_screenshot_errors_clearly() {
6578        let bridge = Arc::new(ConfigBridge::new(vec![
6579            window("main", true),
6580            window("briefing", false),
6581        ]));
6582        let h = handler_with(bridge.clone());
6583        let r = h
6584            .screenshot(Parameters(ScreenshotParams {
6585                window_label: Some("briefing".to_string()),
6586            }))
6587            .await;
6588        assert_eq!(r.is_error, Some(true), "hidden window must error");
6589        let text = error_text(&r);
6590        assert!(
6591            text.contains("not visible"),
6592            "error must explain the window is not visible, got: {text}"
6593        );
6594        assert!(
6595            bridge.requested_handle().is_none(),
6596            "must short-circuit BEFORE the OS-handle/capture path"
6597        );
6598    }
6599
6600    #[tokio::test]
6601    async fn visible_window_screenshot_proceeds_to_capture() {
6602        let bridge = Arc::new(ConfigBridge::new(vec![
6603            window("main", true),
6604            window("briefing", false),
6605        ]));
6606        let h = handler_with(bridge.clone());
6607        let r = h
6608            .screenshot(Parameters(ScreenshotParams {
6609                window_label: Some("main".to_string()),
6610            }))
6611            .await;
6612        // The gate must let a visible window THROUGH to the OS-handle path (which this mock
6613        // fails) — proving the gate only blocks hidden windows.
6614        let text = error_text(&r);
6615        assert!(
6616            text.contains("native handle path reached")
6617                || text.contains("cannot get window handle"),
6618            "a visible window must reach the capture path, got: {text}"
6619        );
6620        assert_eq!(
6621            bridge.requested_handle(),
6622            Some(Some("main".to_string())),
6623            "must capture the explicitly requested visible window"
6624        );
6625    }
6626
6627    // GPT audit (P2): `screenshot {}` with NO label previously resolved through
6628    // find_window(None), which prefers "main" UNCONDITIONALLY — so an app that hides main but
6629    // keeps a secondary window visible captured hidden main (the wrong-pixels class the PR
6630    // exists to prevent). The tool must now resolve its own VISIBLE target.
6631    #[tokio::test]
6632    async fn omitted_label_skips_hidden_main_for_visible_secondary() {
6633        let bridge = Arc::new(ConfigBridge::new(vec![
6634            window("main", false),     // main hidden
6635            window("secondary", true), // a different window is visible
6636        ]));
6637        let h = handler_with(bridge.clone());
6638        let r = h
6639            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6640            .await;
6641        let text = error_text(&r);
6642        assert!(
6643            !text.contains("not visible") && !text.contains("no visible window"),
6644            "a visible secondary window exists — must NOT error, got: {text}"
6645        );
6646        assert_eq!(
6647            bridge.requested_handle(),
6648            Some(Some("secondary".to_string())),
6649            "omitted label must resolve to the VISIBLE secondary, never hidden main"
6650        );
6651    }
6652
6653    // Omitted label with a visible main present must still prefer "main".
6654    #[tokio::test]
6655    async fn omitted_label_prefers_visible_main() {
6656        let bridge = Arc::new(ConfigBridge::new(vec![
6657            window("main", true),
6658            window("secondary", true),
6659        ]));
6660        let h = handler_with(bridge.clone());
6661        let _ = h
6662            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6663            .await;
6664        assert_eq!(
6665            bridge.requested_handle(),
6666            Some(Some("main".to_string())),
6667            "with a visible main present, omitted label must resolve to main"
6668        );
6669    }
6670
6671    // Every window hidden + omitted label: error clearly, never capture a hidden window.
6672    #[tokio::test]
6673    async fn all_hidden_omitted_label_errors() {
6674        let bridge = Arc::new(ConfigBridge::new(vec![
6675            window("main", false),
6676            window("briefing", false),
6677        ]));
6678        let h = handler_with(bridge.clone());
6679        let r = h
6680            .screenshot(Parameters(ScreenshotParams { window_label: None }))
6681            .await;
6682        assert_eq!(r.is_error, Some(true), "all-hidden must error");
6683        assert!(
6684            error_text(&r).contains("no visible window"),
6685            "error must say there is no visible window, got: {}",
6686            error_text(&r)
6687        );
6688        assert!(
6689            bridge.requested_handle().is_none(),
6690            "must NOT reach the OS-handle path when every window is hidden"
6691        );
6692    }
6693
6694    // An unknown explicit label is passed THROUGH to get_native_handle (which produces the
6695    // canonical "window not found"), not rejected as "not visible".
6696    #[tokio::test]
6697    async fn unknown_label_falls_through_to_handle_resolution() {
6698        let bridge = Arc::new(ConfigBridge::new(vec![window("main", true)]));
6699        let h = handler_with(bridge.clone());
6700        let r = h
6701            .screenshot(Parameters(ScreenshotParams {
6702                window_label: Some("ghost".to_string()),
6703            }))
6704            .await;
6705        assert!(
6706            !error_text(&r).contains("not visible"),
6707            "unknown label must not be reported as 'not visible'"
6708        );
6709        assert_eq!(
6710            bridge.requested_handle(),
6711            Some(Some("ghost".to_string())),
6712            "unknown label must be forwarded verbatim to get_native_handle"
6713        );
6714    }
6715}