Skip to main content

victauri_plugin/mcp/
mod.rs

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