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