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 backend_params;
8mod compound_params;
9mod helpers;
10mod introspection_params;
11mod other_params;
12mod rest;
13mod server;
14mod verification_params;
15mod webview_params;
16mod window_params;
17
18use std::collections::{HashMap, HashSet};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use rmcp::handler::server::tool::ToolCallContext;
23use rmcp::handler::server::wrapper::Parameters;
24use rmcp::model::{
25    AnnotateAble, CallToolRequestParams, CallToolResult, Content, ListResourcesResult,
26    ListToolsResult, PaginatedRequestParams, RawContent, RawResource, ReadResourceRequestParams,
27    ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
28    Tool, UnsubscribeRequestParams,
29};
30use rmcp::service::RequestContext;
31use rmcp::{ErrorData, RoleServer, ServerHandler, tool, tool_router};
32use tokio::sync::Mutex;
33
34use crate::VictauriState;
35use crate::bridge::WebviewBridge;
36
37use helpers::{
38    RecoveryHint, js_string, json_result, missing_param, sanitize_css_color, sanitize_injected_css,
39    tool_disabled, tool_error, tool_error_with_hint, validate_url,
40};
41
42pub use backend_params::*;
43pub use compound_params::*;
44pub use introspection_params::*;
45pub use other_params::{
46    DiagnosticsParams, FindElementsParams, ResolveCommandParams, SemanticAssertParams,
47    WaitCondition, WaitForParams,
48};
49pub use server::*;
50pub use verification_params::*;
51pub use webview_params::*;
52pub use window_params::*;
53
54// ── MCP Handler ──────────────────────────────────────────────────────────────
55
56/// Maximum number of in-flight JavaScript eval requests. Prevents unbounded
57/// growth of the `pending_evals` map if callbacks are never resolved.
58pub(crate) const MAX_PENDING_EVALS: usize = 100;
59
60fn chrono_now() -> String {
61    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
62}
63
64/// Maximum length of JavaScript code accepted by the `eval_js` tool (1 MB).
65const MAX_EVAL_CODE_LEN: usize = 1_000_000;
66
67/// Maximum length of a JavaScript eval return value (5 MB).
68/// Results exceeding this are truncated to prevent memory exhaustion.
69const MAX_EVAL_RESULT_LEN: usize = 5_000_000;
70
71/// How long the eval parse-watchdog waits for the user-code script to begin executing
72/// before reporting a likely syntax error. A parse error means the script never runs (so
73/// it never marks itself "started"); this caps that failure at ~0.75s instead of the full
74/// eval timeout, while still leaving valid-but-slow code to run to the real timeout.
75const PARSE_WATCHDOG_MS: u64 = 750;
76
77/// Default number of entries returned by IPC/network log tools when no explicit
78/// `limit` is given. Prevents busy apps (large logs) from exceeding the eval cap.
79const DEFAULT_LOG_LIMIT: usize = 100;
80
81/// Per-field byte cap applied to each IPC/network log entry before serialization.
82/// Large request/response bodies are truncated with a marker so the aggregate
83/// log stays well under [`MAX_EVAL_RESULT_LEN`] even on heavy-traffic apps.
84const MAX_LOG_FIELD_BYTES: usize = 4096;
85
86const RESOURCE_URI_IPC_LOG: &str = "victauri://ipc-log";
87const RESOURCE_URI_WINDOWS: &str = "victauri://windows";
88const RESOURCE_URI_STATE: &str = "victauri://state";
89
90const BRIDGE_VERSION: &str = "0.5.0";
91
92const SAFE_ENV_PREFIXES: &[&str] = &[
93    "HOME",
94    "USER",
95    "LANG",
96    "LC_",
97    "TERM",
98    "SHELL",
99    "DISPLAY",
100    "XDG_",
101    // Only Tauri's build-env namespace, NOT all of TAURI_ — the latter is an
102    // app-custom namespace that can hold secrets (audit #5).
103    "TAURI_ENV_",
104    "VICTAURI_",
105    "NODE_ENV",
106    "OS",
107    "HOSTNAME",
108    "PWD",
109    "SHLVL",
110    "LOGNAME",
111];
112
113/// Substrings that mark an env var as a secret. Even when a name matches a
114/// `SAFE_ENV_PREFIXES` entry it is dropped if it contains one of these — a prefix
115/// like `TAURI_`/`VICTAURI_` otherwise leaks `TAURI_SIGNING_PRIVATE_KEY`,
116/// `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, or `VICTAURI_AUTH_TOKEN` (audit #5).
117const SECRET_ENV_SUBSTRINGS: &[&str] = &[
118    "TOKEN",
119    "SECRET",
120    "PASS", // PASSWORD, PASSWD, PASSPHRASE
121    "PRIVATE",
122    "CREDENTIAL",
123    "APIKEY",
124    "AUTH",
125    "_KEY",
126    "DSN", // connection strings with embedded creds
127    "PAT", // personal access token
128    "JWT",
129    "BEARER",
130    "SESSION",
131    "COOKIE",
132    "SALT",
133    "CERT",
134    "SIGN", // signing keys/material
135    "LICENSE",
136];
137
138/// Whether an env var name is safe to surface via `app_info`: it must match a
139/// known-safe prefix AND not look like a secret (audit #5).
140fn is_safe_env_key(key: &str) -> bool {
141    let upper = key.to_uppercase();
142    SAFE_ENV_PREFIXES
143        .iter()
144        .any(|prefix| upper.starts_with(prefix))
145        && !SECRET_ENV_SUBSTRINGS.iter().any(|s| upper.contains(s))
146}
147
148/// MCP tool handler that dispatches tool calls to the webview bridge and state.
149#[derive(Clone)]
150pub struct VictauriMcpHandler {
151    state: Arc<VictauriState>,
152    bridge: Arc<dyn WebviewBridge>,
153    subscriptions: Arc<Mutex<HashSet<String>>>,
154    bridge_checked: Arc<AtomicBool>,
155    probed_labels: Arc<Mutex<HashSet<String>>>,
156    /// Window keys whose previous eval timed out. The next eval on such a window
157    /// does a fast liveness probe so a reloaded/crashed bridge fails fast with a
158    /// clear error instead of blocking the full timeout again.
159    timed_out_labels: Arc<Mutex<HashSet<String>>>,
160}
161
162#[tool_router]
163impl VictauriMcpHandler {
164    // ── Standalone Tools ────────────────────────────────────────────────────
165
166    #[tool(
167        description = "Evaluate JavaScript in the Tauri webview and return the result. Async expressions are wrapped automatically.",
168        annotations(
169            read_only_hint = false,
170            destructive_hint = true,
171            idempotent_hint = false,
172            open_world_hint = false
173        )
174    )]
175    async fn eval_js(&self, Parameters(params): Parameters<EvalJsParams>) -> CallToolResult {
176        if !self.state.privacy.is_tool_enabled("eval_js") {
177            return tool_disabled("eval_js");
178        }
179        if params.code.len() > MAX_EVAL_CODE_LEN {
180            return tool_error("code exceeds maximum length (1 MB)");
181        }
182        match self
183            .eval_with_return(&params.code, params.webview_label.as_deref())
184            .await
185        {
186            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
187            Err(e) => tool_error(e),
188        }
189    }
190
191    #[tool(
192        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).",
193        annotations(
194            read_only_hint = true,
195            destructive_hint = false,
196            idempotent_hint = true,
197            open_world_hint = false
198        )
199    )]
200    async fn dom_snapshot(&self, Parameters(params): Parameters<SnapshotParams>) -> CallToolResult {
201        let format = params.format.unwrap_or(SnapshotFormat::Compact);
202        let format_str = match format {
203            SnapshotFormat::Compact => "compact",
204            SnapshotFormat::Json => "json",
205        };
206        let code = format!(
207            "return window.__VICTAURI__?.snapshot({})",
208            js_string(format_str)
209        );
210        self.eval_bridge(&code, params.webview_label.as_deref())
211            .await
212    }
213
214    #[tool(
215        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.",
216        annotations(
217            read_only_hint = true,
218            destructive_hint = false,
219            idempotent_hint = true,
220            open_world_hint = false
221        )
222    )]
223    async fn find_elements(
224        &self,
225        Parameters(params): Parameters<FindElementsParams>,
226    ) -> CallToolResult {
227        let mut parts: Vec<String> = Vec::new();
228        if let Some(t) = &params.text {
229            parts.push(format!("text: {}", js_string(t)));
230        }
231        if let Some(r) = &params.role {
232            parts.push(format!("role: {}", js_string(r)));
233        }
234        if let Some(tid) = &params.test_id {
235            parts.push(format!("test_id: {}", js_string(tid)));
236        }
237        if let Some(c) = params.css.as_ref().or(params.selector.as_ref()) {
238            parts.push(format!("css: {}", js_string(c)));
239        }
240        if let Some(n) = &params.name {
241            parts.push(format!("name: {}", js_string(n)));
242        }
243        if let Some(max) = params.max_results {
244            parts.push(format!("max_results: {max}"));
245        }
246        if let Some(t) = &params.tag {
247            parts.push(format!("tag: {}", js_string(t)));
248        }
249        if let Some(p) = &params.placeholder {
250            parts.push(format!("placeholder: {}", js_string(p)));
251        }
252        if let Some(a) = &params.alt {
253            parts.push(format!("alt: {}", js_string(a)));
254        }
255        if let Some(ta) = &params.title_attr {
256            parts.push(format!("title_attr: {}", js_string(ta)));
257        }
258        if let Some(l) = &params.label {
259            parts.push(format!("label: {}", js_string(l)));
260        }
261        if let Some(true) = params.exact {
262            parts.push("exact: true".to_string());
263        }
264        if let Some(e) = params.enabled {
265            parts.push(format!("enabled: {e}"));
266        }
267        let code = format!(
268            "return window.__VICTAURI__?.findElements({{ {} }})",
269            parts.join(", ")
270        );
271        match self
272            .eval_with_return(&code, params.webview_label.as_deref())
273            .await
274        {
275            Ok(result) => {
276                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
277                    && let Some(err) = parsed.get("error").and_then(|e| e.as_str())
278                {
279                    return tool_error(err);
280                }
281                CallToolResult::success(vec![Content::text(result)])
282            }
283            Err(e) => tool_error(e),
284        }
285    }
286
287    #[tool(
288        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.",
289        annotations(
290            read_only_hint = false,
291            destructive_hint = true,
292            idempotent_hint = false,
293            open_world_hint = false
294        )
295    )]
296    async fn invoke_command(
297        &self,
298        Parameters(params): Parameters<InvokeCommandParams>,
299    ) -> CallToolResult {
300        if !self.state.privacy.is_invoke_allowed(&params.command) {
301            return tool_disabled("invoke_command");
302        }
303        if !self.state.privacy.is_command_allowed(&params.command) {
304            return tool_error(format!(
305                "command '{}' is blocked by privacy configuration",
306                params.command
307            ));
308        }
309
310        // ── Fault injection check ──
311        if let Some(fault) = self.state.fault_registry.check_and_trigger(&params.command) {
312            match fault {
313                crate::introspection::FaultType::Delay { delay_ms } => {
314                    tracing::info!(
315                        command = %params.command,
316                        delay_ms = delay_ms,
317                        "fault injection: delaying command"
318                    );
319                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
320                    // After delay, continue with normal execution below
321                }
322                crate::introspection::FaultType::Error { ref message } => {
323                    tracing::info!(
324                        command = %params.command,
325                        "fault injection: returning error"
326                    );
327                    return tool_error(format!(
328                        "[FAULT INJECTED] command '{}': {message}",
329                        params.command
330                    ));
331                }
332                crate::introspection::FaultType::Drop => {
333                    tracing::info!(
334                        command = %params.command,
335                        "fault injection: dropping response"
336                    );
337                    return CallToolResult::success(vec![Content::text("{}")]);
338                }
339                crate::introspection::FaultType::Corrupt => {
340                    tracing::info!(
341                        command = %params.command,
342                        "fault injection: corrupting response"
343                    );
344                    // Execute normally but mangle the response
345                    let args_json = params.args.unwrap_or(serde_json::json!({}));
346                    let args_str =
347                        serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
348                    let code = format!(
349                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
350                        js_string(&params.command)
351                    );
352                    if let Ok(result) = self
353                        .eval_with_return(&code, params.webview_label.as_deref())
354                        .await
355                    {
356                        let corrupted = format!(
357                            "{{\"__corrupted\":true,\"original_length\":{},\"fault\":\"corrupt\"}}",
358                            result.len()
359                        );
360                        return CallToolResult::success(vec![Content::text(corrupted)]);
361                    }
362                    return CallToolResult::success(vec![Content::text(
363                        "{\"__corrupted\":true,\"fault\":\"corrupt\",\"note\":\"original invocation also failed\"}",
364                    )]);
365                }
366            }
367        }
368
369        // ── Normal execution with timing ──
370        let start = std::time::Instant::now();
371        let args_json = params.args.unwrap_or(serde_json::json!({}));
372        let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
373        let code = format!(
374            "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
375            js_string(&params.command)
376        );
377        let result = self
378            .eval_with_return(&code, params.webview_label.as_deref())
379            .await;
380        let elapsed = start.elapsed();
381        self.state.command_timings.record(&params.command, elapsed);
382
383        match result {
384            Ok(result) => {
385                if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result)
386                    && let Some(err) = parsed.get("__error").and_then(|e| e.as_str())
387                {
388                    return tool_error(format!(
389                        "command '{}' returned error: {err}",
390                        params.command
391                    ));
392                }
393                CallToolResult::success(vec![Content::text(result)])
394            }
395            Err(e) => tool_error(format!("invoke_command failed: {e}")),
396        }
397    }
398
399    #[tool(
400        description = "Capture a screenshot of a Tauri window as a base64-encoded PNG image. Works on Windows (PrintWindow), macOS (CGWindowListCreateImage), and Linux (X11/Wayland).",
401        annotations(
402            read_only_hint = true,
403            destructive_hint = false,
404            idempotent_hint = true,
405            open_world_hint = false
406        )
407    )]
408    async fn screenshot(&self, Parameters(params): Parameters<ScreenshotParams>) -> CallToolResult {
409        self.track_tool_call();
410        if !self.state.privacy.is_tool_enabled("screenshot") {
411            return tool_disabled("screenshot");
412        }
413        match self
414            .bridge
415            .get_native_handle(params.window_label.as_deref())
416        {
417            Ok(hwnd) => match crate::screenshot::capture_window(hwnd).await {
418                Ok(png_bytes) => {
419                    use base64::Engine;
420                    let b64 = base64::engine::general_purpose::STANDARD.encode(&png_bytes);
421                    CallToolResult::success(vec![Content::image(b64, "image/png")])
422                }
423                Err(e) => tool_error(format!("screenshot capture failed: {e}")),
424            },
425            Err(e) => tool_error(format!("cannot get window handle: {e}")),
426        }
427    }
428
429    #[tool(
430        description = "Compare frontend state (evaluated via JS expression) against backend state to detect divergences. Returns a VerificationResult with any mismatches.",
431        annotations(
432            read_only_hint = true,
433            destructive_hint = false,
434            idempotent_hint = true,
435            open_world_hint = false
436        )
437    )]
438    async fn verify_state(
439        &self,
440        Parameters(params): Parameters<VerifyStateParams>,
441    ) -> CallToolResult {
442        if !self.state.privacy.is_tool_enabled("eval_js") {
443            return tool_disabled("verify_state requires eval_js capability");
444        }
445        let code = format!("return ({})", params.frontend_expr);
446        let frontend_json = match self
447            .eval_with_return(&code, params.webview_label.as_deref())
448            .await
449        {
450            Ok(result) => result,
451            Err(e) => return tool_error(format!("failed to evaluate frontend expression: {e}")),
452        };
453
454        let frontend_state: serde_json::Value = match serde_json::from_str(&frontend_json) {
455            Ok(v) => v,
456            Err(e) => {
457                return tool_error(format!(
458                    "frontend expression did not return valid JSON: {e}"
459                ));
460            }
461        };
462
463        let backend_state = if let Some(state) = params.backend_state {
464            state
465        } else if let Some(ref cmd) = params.backend_command {
466            // Gate on BOTH is_invoke_allowed and is_command_allowed, matching
467            // invoke_command and the contract/replay paths — backend_command
468            // previously checked only the blocklist (audit #30 follow-up).
469            if !self.state.privacy.is_invoke_allowed(cmd)
470                || !self.state.privacy.is_command_allowed(cmd)
471            {
472                return tool_error(format!(
473                    "command '{cmd}' is blocked by privacy configuration"
474                ));
475            }
476            let args = params.backend_args.unwrap_or(serde_json::json!({}));
477            let args_str = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
478            let invoke_code = format!(
479                "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
480                js_string(cmd)
481            );
482            match self
483                .eval_with_return(&invoke_code, params.webview_label.as_deref())
484                .await
485            {
486                Ok(result) => match serde_json::from_str(&result) {
487                    Ok(v) => v,
488                    Err(e) => {
489                        return tool_error(format!(
490                            "backend command '{cmd}' did not return valid JSON: {e}"
491                        ));
492                    }
493                },
494                Err(e) => {
495                    return tool_error(format!("failed to invoke backend command '{cmd}': {e}"));
496                }
497            }
498        } else {
499            return tool_error("either backend_state or backend_command must be provided");
500        };
501
502        let result = victauri_core::verify_state(frontend_state, backend_state);
503        json_result(&result)
504    }
505
506    #[tool(
507        description = "Detect ghost commands — commands invoked from the frontend that have no backend handler, or registered backend commands never called. Reads the JS-side IPC interception log, which ACCUMULATES every command seen this session (including earlier test/probe traffic) — so a 'FrontendOnly' result reflects what was invoked at runtime, not necessarily a real frontend bug. For a clean signal, call `logs {action:'clear'}` first, then exercise the app, then run this.",
508        annotations(
509            read_only_hint = true,
510            destructive_hint = false,
511            idempotent_hint = true,
512            open_world_hint = false
513        )
514    )]
515    async fn detect_ghost_commands(
516        &self,
517        Parameters(params): Parameters<GhostCommandParams>,
518    ) -> CallToolResult {
519        // Project only command names in JS — ghost detection never needs request
520        // or response bodies, so this stays tiny even when the full IPC log is
521        // huge (avoids the eval size cap on busy apps).
522        let code = "return (window.__VICTAURI__?.getIpcLog() || []).map(function(c){ return (c && c.command) || null; }).filter(function(x){ return x; })";
523        let ipc_json = match self
524            .eval_with_return(code, params.webview_label.as_deref())
525            .await
526        {
527            Ok(r) => r,
528            Err(e) => return tool_error(format!("failed to read IPC log: {e}")),
529        };
530
531        let command_names: Vec<String> = match serde_json::from_str(&ipc_json) {
532            Ok(v) => v,
533            Err(e) => return tool_error(format!("failed to parse IPC log JSON: {e}")),
534        };
535        let frontend_commands: Vec<String> = command_names
536            .into_iter()
537            .collect::<std::collections::HashSet<_>>()
538            .into_iter()
539            .collect();
540
541        let report = victauri_core::detect_ghost_commands(&frontend_commands, &self.state.registry);
542        json_result(&report)
543    }
544
545    #[tool(
546        description = "Check IPC round-trip integrity: find stale (stuck) pending calls and errored calls. Returns health status and lists of problematic IPC calls.",
547        annotations(
548            read_only_hint = true,
549            destructive_hint = false,
550            idempotent_hint = true,
551            open_world_hint = false
552        )
553    )]
554    async fn check_ipc_integrity(
555        &self,
556        Parameters(params): Parameters<IpcIntegrityParams>,
557    ) -> CallToolResult {
558        let threshold = params.stale_threshold_ms.unwrap_or(5000);
559        let code = format!(
560            r"return (function() {{
561                var log = window.__VICTAURI__?.getIpcLog() || [];
562                var now = Date.now();
563                var threshold = {threshold};
564                var pending = log.filter(function(c) {{ return c.status === 'pending'; }});
565                var stale = pending.filter(function(c) {{ return (now - c.timestamp) > threshold; }});
566                var errored = log.filter(function(c) {{ return c.status === 'error'; }});
567                var net = window.__VICTAURI__?.getNetworkLog() || [];
568                var warning = null;
569                if (log.length === 0 && net.length > 5) {{
570                    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.';
571                }}
572                return {{
573                    healthy: stale.length === 0 && errored.length === 0,
574                    total_calls: log.length,
575                    pending_count: pending.length,
576                    stale_count: stale.length,
577                    error_count: errored.length,
578                    stale_calls: stale.slice(0, 20),
579                    errored_calls: errored.slice(0, 20),
580                    warning: warning
581                }};
582            }})()"
583        );
584        self.eval_bridge(&code, params.webview_label.as_deref())
585            .await
586    }
587
588    #[tool(
589        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).",
590        annotations(
591            read_only_hint = true,
592            destructive_hint = false,
593            idempotent_hint = true,
594            open_world_hint = false
595        )
596    )]
597    async fn wait_for(&self, Parameters(params): Parameters<WaitForParams>) -> CallToolResult {
598        let value = params
599            .value
600            .as_ref()
601            .map_or_else(|| "null".to_string(), |v| js_string(v));
602        let timeout_ms = params.timeout_ms.unwrap_or(10_000).min(60_000);
603        let poll = params.poll_ms.unwrap_or(200);
604        let code = format!(
605            "return window.__VICTAURI__?.waitFor({{ condition: {}, value: {value}, timeout_ms: {timeout_ms}, poll_ms: {poll} }})",
606            js_string(params.condition.as_str())
607        );
608        let eval_timeout = std::time::Duration::from_millis(timeout_ms + 5000);
609        match self
610            .eval_with_return_timeout(&code, params.webview_label.as_deref(), eval_timeout)
611            .await
612        {
613            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
614            Err(e) => tool_error(e),
615        }
616    }
617
618    #[tool(
619        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.",
620        annotations(
621            read_only_hint = true,
622            destructive_hint = false,
623            idempotent_hint = true,
624            open_world_hint = false
625        )
626    )]
627    async fn assert_semantic(
628        &self,
629        Parameters(params): Parameters<SemanticAssertParams>,
630    ) -> CallToolResult {
631        if !self.state.privacy.is_tool_enabled("eval_js") {
632            return tool_disabled("assert_semantic requires eval_js capability");
633        }
634        let code = format!("return ({})", params.expression);
635        let actual_json = match self
636            .eval_with_return(&code, params.webview_label.as_deref())
637            .await
638        {
639            Ok(result) => result,
640            Err(e) => return tool_error(format!("failed to evaluate expression: {e}")),
641        };
642
643        let actual: serde_json::Value = match serde_json::from_str(&actual_json) {
644            Ok(v) => v,
645            Err(e) => return tool_error(format!("expression did not return valid JSON: {e}")),
646        };
647
648        let assertion = victauri_core::SemanticAssertion {
649            label: params.label,
650            condition: params.condition,
651            expected: params.expected,
652        };
653
654        let result = victauri_core::evaluate_assertion(actual, &assertion);
655        json_result(&result)
656    }
657
658    #[tool(
659        description = "Resolve a natural language query to matching Tauri commands. Returns scored results ranked by relevance, using command names, descriptions, intents, categories, and examples.",
660        annotations(
661            read_only_hint = true,
662            destructive_hint = false,
663            idempotent_hint = true,
664            open_world_hint = false
665        )
666    )]
667    async fn resolve_command(
668        &self,
669        Parameters(params): Parameters<ResolveCommandParams>,
670    ) -> CallToolResult {
671        self.track_tool_call();
672        let limit = params.limit.unwrap_or(5);
673        let mut results = self.state.registry.resolve(&params.query);
674        results.truncate(limit);
675        json_result(&results)
676    }
677
678    #[tool(
679        description = "List or search all registered Tauri commands with their argument schemas. Pass query to filter by name/description substring. Commands are registered via #[inspectable] macro.",
680        annotations(
681            read_only_hint = true,
682            destructive_hint = false,
683            idempotent_hint = true,
684            open_world_hint = false
685        )
686    )]
687    async fn get_registry(&self, Parameters(params): Parameters<RegistryParams>) -> CallToolResult {
688        self.track_tool_call();
689        let commands = match params.query {
690            Some(q) => self.state.registry.search(&q),
691            None => self.state.registry.list(),
692        };
693        json_result(&commands)
694    }
695
696    #[tool(
697        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.",
698        annotations(
699            read_only_hint = true,
700            destructive_hint = false,
701            idempotent_hint = true,
702            open_world_hint = false
703        )
704    )]
705    async fn get_memory_stats(&self) -> CallToolResult {
706        self.track_tool_call();
707        let stats = crate::memory::current_stats();
708        json_result(&stats)
709    }
710
711    #[tool(
712        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.",
713        annotations(
714            read_only_hint = true,
715            destructive_hint = false,
716            idempotent_hint = true,
717            open_world_hint = false
718        )
719    )]
720    async fn get_plugin_info(&self) -> CallToolResult {
721        self.track_tool_call();
722        let disabled: Vec<&str> = self
723            .state
724            .privacy
725            .disabled_tools
726            .iter()
727            .map(std::string::String::as_str)
728            .collect();
729        let blocklist: Vec<&str> = self
730            .state
731            .privacy
732            .command_blocklist
733            .iter()
734            .map(std::string::String::as_str)
735            .collect();
736        let allowlist: Option<Vec<&str>> = self
737            .state
738            .privacy
739            .command_allowlist
740            .as_ref()
741            .map(|s| s.iter().map(std::string::String::as_str).collect());
742        let all_tools = Self::tool_router().list_all();
743        let enabled_tools: Vec<&str> = all_tools
744            .iter()
745            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
746            .map(|t| t.name.as_ref())
747            .collect();
748
749        let result = serde_json::json!({
750            "version": env!("CARGO_PKG_VERSION"),
751            "bridge_version": BRIDGE_VERSION,
752            "port": self.state.port.load(Ordering::Relaxed),
753            "tools": {
754                "total": all_tools.len(),
755                "enabled": enabled_tools.len(),
756                "enabled_list": enabled_tools,
757                "disabled_list": disabled,
758            },
759            "commands": {
760                "allowlist": allowlist,
761                "blocklist": blocklist,
762            },
763            "privacy": {
764                "profile": self.state.privacy.profile.to_string(),
765                "redaction_enabled": self.state.privacy.redaction_enabled,
766            },
767            "capacities": {
768                "event_log": self.state.event_log.capacity(),
769                "eval_timeout_secs": self.state.eval_timeout.as_secs(),
770            },
771            "registered_commands": self.state.registry.count(),
772            "tool_invocations": self.state.tool_invocations.load(std::sync::atomic::Ordering::Relaxed),
773            "uptime_secs": self.state.started_at.elapsed().as_secs(),
774        });
775        json_result(&result)
776    }
777
778    #[tool(
779        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.",
780        annotations(
781            read_only_hint = true,
782            destructive_hint = false,
783            idempotent_hint = true,
784            open_world_hint = false
785        )
786    )]
787    async fn get_diagnostics(
788        &self,
789        Parameters(params): Parameters<DiagnosticsParams>,
790    ) -> CallToolResult {
791        self.eval_bridge(
792            "return window.__VICTAURI__?.getDiagnostics()",
793            params.webview_label.as_deref(),
794        )
795        .await
796    }
797
798    // ── Backend Access Tools ───────────────────────────────────────────────
799
800    #[tool(
801        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.",
802        annotations(
803            read_only_hint = true,
804            destructive_hint = false,
805            idempotent_hint = true,
806            open_world_hint = false
807        )
808    )]
809    async fn app_info(&self) -> CallToolResult {
810        self.track_tool_call();
811        let config = self.bridge.tauri_config();
812
813        let data_dir = self.bridge.app_data_dir().ok();
814        let config_dir = self.bridge.app_config_dir().ok();
815        let log_dir = self.bridge.app_log_dir().ok();
816        let local_data_dir = self.bridge.app_local_data_dir().ok();
817
818        let env_vars: std::collections::BTreeMap<String, String> = std::env::vars()
819            .filter(|(k, _)| is_safe_env_key(k))
820            .collect();
821
822        // Enumerate every database candidate across ALL roots (configured db_search_paths
823        // + every OS app dir), each tagged with size, whether it is a WebView/engine
824        // internal store, and whether it is the one `query_db` would auto-select. This lets
825        // an agent see and disambiguate the real app DB instead of guessing (audit /
826        // red-team "wrong DB" finding — `app_info.databases` previously only walked
827        // data_dir and returned bare relative names).
828        #[cfg(feature = "sqlite")]
829        let databases: Vec<serde_json::Value> = {
830            let mut all_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
831            for d in [
832                data_dir.as_ref(),
833                config_dir.as_ref(),
834                log_dir.as_ref(),
835                local_data_dir.as_ref(),
836            ]
837            .into_iter()
838            .flatten()
839            {
840                all_dirs.push(d.clone());
841            }
842            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
843                all_dirs.clone()
844            } else {
845                self.state.db_search_paths.clone()
846            };
847            let selected = crate::database::select_app_database(&select_dirs).ok();
848            crate::database::classify_databases(&all_dirs)
849                .into_iter()
850                .map(|c| {
851                    serde_json::json!({
852                        "path": c.path.to_string_lossy(),
853                        "size_bytes": c.size_bytes,
854                        "webview_internal": c.webview_internal,
855                        "selected": selected.as_ref() == Some(&c.path),
856                    })
857                })
858                .collect()
859        };
860
861        #[cfg(not(feature = "sqlite"))]
862        let databases: Vec<serde_json::Value> = Vec::new();
863
864        let result = serde_json::json!({
865            "config": config,
866            "paths": {
867                "data": data_dir.as_ref().map(|p| p.to_string_lossy()),
868                "config": config_dir.as_ref().map(|p| p.to_string_lossy()),
869                "log": log_dir.as_ref().map(|p| p.to_string_lossy()),
870                "local_data": local_data_dir.as_ref().map(|p| p.to_string_lossy()),
871            },
872            "databases": databases,
873            "env": env_vars,
874            "process": {
875                "pid": std::process::id(),
876                "arch": std::env::consts::ARCH,
877                "os": std::env::consts::OS,
878                "family": std::env::consts::FAMILY,
879            },
880        });
881        json_result(&result)
882    }
883
884    #[tool(
885        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.",
886        annotations(
887            read_only_hint = true,
888            destructive_hint = false,
889            idempotent_hint = true,
890            open_world_hint = false
891        )
892    )]
893    async fn list_app_dir(
894        &self,
895        Parameters(params): Parameters<ListAppDirParams>,
896    ) -> CallToolResult {
897        self.track_tool_call();
898        let base = match self.resolve_app_dir(params.directory) {
899            Ok(d) => d,
900            Err(e) => return tool_error(e),
901        };
902
903        let target = if let Some(ref sub) = params.path {
904            let resolved = base.join(sub);
905            if !resolved.exists() {
906                return tool_error(format!("directory does not exist: {}", resolved.display()));
907            }
908            if let Err(e) = Self::safe_within(&base, &resolved) {
909                return tool_error(e);
910            }
911            resolved
912        } else {
913            base.clone()
914        };
915
916        if !target.exists() {
917            return tool_error(format!("directory does not exist: {}", target.display()));
918        }
919
920        let max_depth = params.max_depth.unwrap_or(1).min(5);
921        let pattern = params.pattern.as_deref();
922        let mut entries = Vec::new();
923
924        Self::list_dir_recursive(&target, &base, 0, max_depth, pattern, &mut entries);
925
926        json_result(&serde_json::json!({
927            "base": base.to_string_lossy(),
928            "path": params.path.unwrap_or_default(),
929            "entries": entries,
930            "count": entries.len(),
931        }))
932    }
933
934    #[tool(
935        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.",
936        annotations(
937            read_only_hint = true,
938            destructive_hint = false,
939            idempotent_hint = true,
940            open_world_hint = false
941        )
942    )]
943    async fn read_app_file(
944        &self,
945        Parameters(params): Parameters<ReadAppFileParams>,
946    ) -> CallToolResult {
947        self.track_tool_call();
948        let base = match self.resolve_app_dir(params.directory) {
949            Ok(d) => d,
950            Err(e) => return tool_error(e),
951        };
952
953        let target = base.join(&params.path);
954        if !target.exists() {
955            return tool_error(format!("file not found: {}", params.path));
956        }
957        if let Err(e) = Self::safe_within(&base, &target) {
958            return tool_error(e);
959        }
960        if !target.is_file() {
961            return tool_error(format!("not a file: {}", params.path));
962        }
963
964        let max_bytes = params.max_bytes.unwrap_or(1_048_576).min(10_485_760);
965        let metadata = std::fs::metadata(&target).map_err(|e| e.to_string());
966
967        match std::fs::read(&target) {
968            Ok(mut bytes) => {
969                let original_size = bytes.len();
970                let truncated = bytes.len() > max_bytes;
971                if truncated {
972                    bytes.truncate(max_bytes);
973                }
974
975                let file_info = serde_json::json!({
976                    "path": params.path,
977                    "size": original_size,
978                    "truncated": truncated,
979                    "modified": metadata.as_ref().ok()
980                        .and_then(|m| m.modified().ok())
981                        .map(|t| {
982                            let duration = t.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap_or_default();
983                            duration.as_secs()
984                        }),
985                });
986
987                if params.binary == Some(true) {
988                    use base64::Engine;
989                    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
990                    json_result(&serde_json::json!({
991                        "file": file_info,
992                        "encoding": "base64",
993                        "content": b64,
994                    }))
995                } else {
996                    match String::from_utf8(bytes) {
997                        Ok(text) => json_result(&serde_json::json!({
998                            "file": file_info,
999                            "encoding": "utf-8",
1000                            "content": text,
1001                        })),
1002                        Err(e) => {
1003                            use base64::Engine;
1004                            let bytes = e.into_bytes();
1005                            let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1006                            json_result(&serde_json::json!({
1007                                "file": file_info,
1008                                "encoding": "base64",
1009                                "note": "file is not valid UTF-8, returning base64",
1010                                "content": b64,
1011                            }))
1012                        }
1013                    }
1014                }
1015            }
1016            Err(e) => tool_error(format!("failed to read file: {e}")),
1017        }
1018    }
1019
1020    #[cfg(feature = "sqlite")]
1021    #[tool(
1022        description = "Execute a read-only SQL query against a SQLite database in the app's data directory. Auto-discovers database files if no path is specified. Only SELECT/PRAGMA/EXPLAIN/WITH queries are allowed. Returns rows as JSON objects with column names as keys. This provides direct backend database access without going through the webview or IPC.",
1023        annotations(
1024            read_only_hint = true,
1025            destructive_hint = false,
1026            idempotent_hint = true,
1027            open_world_hint = false
1028        )
1029    )]
1030    async fn query_db(&self, Parameters(params): Parameters<QueryDbParams>) -> CallToolResult {
1031        self.track_tool_call();
1032        let data_dir = match self.bridge.app_data_dir() {
1033            Ok(d) => d,
1034            Err(e) => return tool_error(format!("cannot access app data directory: {e}")),
1035        };
1036
1037        let app_dirs: Vec<std::path::PathBuf> = [
1038            self.bridge.app_data_dir(),
1039            self.bridge.app_config_dir(),
1040            self.bridge.app_local_data_dir(),
1041            self.bridge.app_log_dir(),
1042        ]
1043        .into_iter()
1044        .filter_map(Result::ok)
1045        .collect::<std::collections::HashSet<_>>()
1046        .into_iter()
1047        .collect();
1048        // Explicitly-configured roots (VictauriBuilder::db_search_paths) take
1049        // precedence over OS app directories for auto-discovery, so a configured
1050        // application DB wins over incidental ones (e.g. WebView internals).
1051        let mut search_dirs: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
1052        search_dirs.extend(app_dirs);
1053
1054        let db_path = if let Some(ref rel_path) = params.path {
1055            let candidate = std::path::Path::new(rel_path);
1056            // Absolute paths are permitted only when they resolve within one of
1057            // the allowed roots (app directories or configured db_search_paths).
1058            if candidate.is_absolute() {
1059                if !candidate.exists() {
1060                    return tool_error(format!("database not found: {rel_path}"));
1061                }
1062                if !search_dirs
1063                    .iter()
1064                    .any(|d| Self::safe_within(d, candidate).is_ok())
1065                {
1066                    return tool_error(format!(
1067                        "absolute path '{rel_path}' is not within an allowed directory; \
1068                         register its parent via VictauriBuilder::db_search_paths"
1069                    ));
1070                }
1071            }
1072            let mut found = None;
1073            if candidate.is_absolute() {
1074                found = Some(candidate.to_path_buf());
1075            } else {
1076                for dir in &search_dirs {
1077                    let resolved = dir.join(rel_path);
1078                    if resolved.exists() {
1079                        if let Err(e) = Self::safe_within(dir, &resolved) {
1080                            return tool_error(e);
1081                        }
1082                        found = Some(resolved);
1083                        break;
1084                    }
1085                }
1086            }
1087            if let Some(p) = found {
1088                p
1089            } else {
1090                let dirs_str = search_dirs
1091                    .iter()
1092                    .map(|d| d.display().to_string())
1093                    .collect::<Vec<_>>()
1094                    .join(", ");
1095                return tool_error(format!(
1096                    "database not found: {rel_path} (searched: {dirs_str})"
1097                ));
1098            }
1099        } else {
1100            // Auto-select the application DB. When db_search_paths is configured it is
1101            // EXCLUSIVE — never fall back to OS app dirs (which hold WebView internals),
1102            // so a configured-but-empty root yields a clear error instead of silently
1103            // querying the wrong database. WebView/browser-engine internal stores are
1104            // excluded and the largest remaining candidate wins (audit / red-team "wrong
1105            // DB" finding).
1106            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
1107                search_dirs.clone()
1108            } else {
1109                self.state.db_search_paths.clone()
1110            };
1111            match crate::database::select_app_database(&select_dirs) {
1112                Ok(p) => p,
1113                Err(e) => return tool_error(e),
1114            }
1115        };
1116
1117        let db_display = db_path
1118            .strip_prefix(&data_dir)
1119            .unwrap_or(&db_path)
1120            .to_string_lossy()
1121            .into_owned();
1122        let bind_params = params.params.unwrap_or_default();
1123
1124        match crate::database::query(&db_path, &params.query, &bind_params, params.max_rows) {
1125            Ok(mut result) => {
1126                if let Some(obj) = result.as_object_mut() {
1127                    obj.insert("database".to_string(), serde_json::json!(db_display));
1128                }
1129                json_result(&result)
1130            }
1131            Err(e) => tool_error(e),
1132        }
1133    }
1134
1135    // ── Compound Tools ──────────────────────────────────────────────────────
1136
1137    #[tool(
1138        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.",
1139        annotations(
1140            read_only_hint = false,
1141            destructive_hint = false,
1142            idempotent_hint = false,
1143            open_world_hint = false
1144        )
1145    )]
1146    async fn interact(&self, Parameters(params): Parameters<InteractParams>) -> CallToolResult {
1147        if !self.state.privacy.is_tool_enabled("interact") {
1148            return tool_disabled("interact");
1149        }
1150        match params.action {
1151            InteractAction::Click => {
1152                if !self.state.privacy.is_tool_enabled("interact.click") {
1153                    return tool_disabled("interact.click");
1154                }
1155                let Some(ref_id) = &params.ref_id else {
1156                    return missing_param("ref_id", "click");
1157                };
1158                if params.trusted.unwrap_or(false) {
1159                    // Resolve the element's viewport-center coords, run the
1160                    // actionability check, then deliver a real OS click.
1161                    let probe = format!(
1162                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); \
1163                         if(!__e) return null; __e.scrollIntoView({{block:'center',inline:'center',behavior:'instant'}}); \
1164                         var __b=__e.getBoundingClientRect(); \
1165                         return {{x:__b.left+__b.width/2, y:__b.top+__b.height/2}}",
1166                        js_string(ref_id)
1167                    );
1168                    let raw = match self
1169                        .eval_with_return(&probe, params.webview_label.as_deref())
1170                        .await
1171                    {
1172                        Ok(r) => r,
1173                        Err(e) => return tool_error(e),
1174                    };
1175                    let Ok(point) = serde_json::from_str::<serde_json::Value>(&raw) else {
1176                        return tool_error_with_hint(
1177                            format!("ref not found: {ref_id}"),
1178                            RecoveryHint::CheckInput,
1179                        );
1180                    };
1181                    let (Some(x), Some(y)) = (
1182                        point.get("x").and_then(serde_json::Value::as_f64),
1183                        point.get("y").and_then(serde_json::Value::as_f64),
1184                    ) else {
1185                        return tool_error_with_hint(
1186                            format!("ref not found: {ref_id}"),
1187                            RecoveryHint::CheckInput,
1188                        );
1189                    };
1190                    return match self
1191                        .bridge
1192                        .native_click(params.webview_label.as_deref(), x, y)
1193                    {
1194                        Ok(()) => json_result(
1195                            &serde_json::json!({"ok": true, "trusted": true, "x": x, "y": y}),
1196                        ),
1197                        Err(e) => tool_error(e),
1198                    };
1199                }
1200                let code = format!("return window.__VICTAURI__?.click({})", js_string(ref_id));
1201                self.eval_bridge(&code, params.webview_label.as_deref())
1202                    .await
1203            }
1204            InteractAction::DoubleClick => {
1205                if !self.state.privacy.is_tool_enabled("interact.double_click") {
1206                    return tool_disabled("interact.double_click");
1207                }
1208                let Some(ref_id) = &params.ref_id else {
1209                    return missing_param("ref_id", "double_click");
1210                };
1211                let code = format!(
1212                    "return window.__VICTAURI__?.doubleClick({})",
1213                    js_string(ref_id)
1214                );
1215                self.eval_bridge(&code, params.webview_label.as_deref())
1216                    .await
1217            }
1218            InteractAction::Hover => {
1219                if !self.state.privacy.is_tool_enabled("interact.hover") {
1220                    return tool_disabled("interact.hover");
1221                }
1222                let Some(ref_id) = &params.ref_id else {
1223                    return missing_param("ref_id", "hover");
1224                };
1225                let code = format!("return window.__VICTAURI__?.hover({})", js_string(ref_id));
1226                self.eval_bridge(&code, params.webview_label.as_deref())
1227                    .await
1228            }
1229            InteractAction::Focus => {
1230                if !self.state.privacy.is_tool_enabled("interact.focus") {
1231                    return tool_disabled("interact.focus");
1232                }
1233                let Some(ref_id) = &params.ref_id else {
1234                    return missing_param("ref_id", "focus");
1235                };
1236                let code = format!(
1237                    "return window.__VICTAURI__?.focusElement({})",
1238                    js_string(ref_id)
1239                );
1240                self.eval_bridge(&code, params.webview_label.as_deref())
1241                    .await
1242            }
1243            InteractAction::ScrollIntoView => {
1244                if !self
1245                    .state
1246                    .privacy
1247                    .is_tool_enabled("interact.scroll_into_view")
1248                {
1249                    return tool_disabled("interact.scroll_into_view");
1250                }
1251                let ref_arg = params
1252                    .ref_id
1253                    .as_ref()
1254                    .map_or_else(|| "null".to_string(), |r| js_string(r));
1255                let x = params.x.unwrap_or(0.0);
1256                let y = params.y.unwrap_or(0.0);
1257                let code = format!("return window.__VICTAURI__?.scrollTo({ref_arg}, {x}, {y})");
1258                self.eval_bridge(&code, params.webview_label.as_deref())
1259                    .await
1260            }
1261            InteractAction::SelectOption => {
1262                if !self.state.privacy.is_tool_enabled("interact.select_option") {
1263                    return tool_disabled("interact.select_option");
1264                }
1265                let Some(ref_id) = &params.ref_id else {
1266                    return missing_param("ref_id", "select_option");
1267                };
1268                let values_vec;
1269                let values: &[String] = match (&params.values, &params.value) {
1270                    (Some(v), _) => v,
1271                    (None, Some(v)) => {
1272                        values_vec = vec![v.clone()];
1273                        &values_vec
1274                    }
1275                    (None, None) => &[],
1276                };
1277                let values_json =
1278                    serde_json::to_string(values).unwrap_or_else(|_| "[]".to_string());
1279                let code = format!(
1280                    "return window.__VICTAURI__?.selectOption({}, {})",
1281                    js_string(ref_id),
1282                    values_json
1283                );
1284                self.eval_bridge(&code, params.webview_label.as_deref())
1285                    .await
1286            }
1287        }
1288    }
1289
1290    #[tool(
1291        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.",
1292        annotations(
1293            read_only_hint = false,
1294            destructive_hint = false,
1295            idempotent_hint = false,
1296            open_world_hint = false
1297        )
1298    )]
1299    async fn input(&self, Parameters(params): Parameters<InputParams>) -> CallToolResult {
1300        match params.action {
1301            InputAction::Fill => {
1302                if !self.state.privacy.is_tool_enabled("fill") {
1303                    return tool_disabled("fill");
1304                }
1305                let Some(ref_id) = &params.ref_id else {
1306                    return missing_param("ref_id", "fill");
1307                };
1308                let Some(value) = &params.value else {
1309                    return missing_param("value", "fill");
1310                };
1311                let code = format!(
1312                    "return window.__VICTAURI__?.fill({}, {})",
1313                    js_string(ref_id),
1314                    js_string(value)
1315                );
1316                self.eval_bridge(&code, params.webview_label.as_deref())
1317                    .await
1318            }
1319            InputAction::TypeText => {
1320                if !self.state.privacy.is_tool_enabled("type_text") {
1321                    return tool_disabled("type_text");
1322                }
1323                let Some(ref_id) = &params.ref_id else {
1324                    return missing_param("ref_id", "type_text");
1325                };
1326                let Some(text) = &params.text else {
1327                    return missing_param("text", "type_text");
1328                };
1329                if params.trusted.unwrap_or(false) {
1330                    // Focus the element via JS, then deliver real OS keystrokes
1331                    // (isTrusted: true) for handlers that reject synthetic events.
1332                    let focus = format!(
1333                        "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1334                        js_string(ref_id)
1335                    );
1336                    let focused = self
1337                        .eval_with_return(&focus, params.webview_label.as_deref())
1338                        .await
1339                        .unwrap_or_default();
1340                    if focused != "true" {
1341                        return tool_error_with_hint(
1342                            format!("ref not found or not focusable: {ref_id}"),
1343                            RecoveryHint::CheckInput,
1344                        );
1345                    }
1346                    return match self
1347                        .bridge
1348                        .native_type_text(params.webview_label.as_deref(), text)
1349                    {
1350                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1351                        Err(e) => tool_error(e),
1352                    };
1353                }
1354                let code = format!(
1355                    "return window.__VICTAURI__?.type({}, {})",
1356                    js_string(ref_id),
1357                    js_string(text)
1358                );
1359                self.eval_bridge(&code, params.webview_label.as_deref())
1360                    .await
1361            }
1362            InputAction::PressKey => {
1363                if !self.state.privacy.is_tool_enabled("input.press_key") {
1364                    return tool_disabled("input.press_key");
1365                }
1366                let Some(key) = &params.key else {
1367                    return missing_param("key", "press_key");
1368                };
1369                if params.trusted.unwrap_or(false) {
1370                    // Optionally focus a target element, then send a real OS key.
1371                    if let Some(ref_id) = &params.ref_id {
1372                        let focus = format!(
1373                            "var __e=window.__VICTAURI__&&window.__VICTAURI__.getRef({}); if(__e){{__e.focus();}} return !!__e",
1374                            js_string(ref_id)
1375                        );
1376                        let _ = self
1377                            .eval_with_return(&focus, params.webview_label.as_deref())
1378                            .await;
1379                    }
1380                    return match self.bridge.native_key(params.webview_label.as_deref(), key) {
1381                        Ok(()) => json_result(&serde_json::json!({"ok": true, "trusted": true})),
1382                        Err(e) => tool_error(e),
1383                    };
1384                }
1385                let code = format!("return window.__VICTAURI__?.pressKey({})", js_string(key));
1386                self.eval_bridge(&code, params.webview_label.as_deref())
1387                    .await
1388            }
1389        }
1390    }
1391
1392    #[tool(
1393        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).",
1394        annotations(
1395            read_only_hint = false,
1396            destructive_hint = false,
1397            idempotent_hint = true,
1398            open_world_hint = false
1399        )
1400    )]
1401    async fn window(&self, Parameters(params): Parameters<WindowParams>) -> CallToolResult {
1402        self.track_tool_call();
1403        match params.action {
1404            WindowAction::GetState => {
1405                let states = self.bridge.get_window_states(params.label.as_deref());
1406                // A specific label that matches no window is an error, not an
1407                // empty array (which reads as "success, no state").
1408                if states.is_empty()
1409                    && let Some(label) = params.label.as_deref()
1410                {
1411                    return tool_error(format!(
1412                        "window not found: '{label}' (use window.list to see available labels)"
1413                    ));
1414                }
1415                json_result(&states)
1416            }
1417            WindowAction::List => {
1418                let labels = self.bridge.list_window_labels();
1419                json_result(&labels)
1420            }
1421            WindowAction::Introspectability => self.window_introspectability().await,
1422            WindowAction::Manage => {
1423                if !self.state.privacy.is_tool_enabled("window.manage") {
1424                    return tool_disabled("window.manage");
1425                }
1426                let Some(manage_action) = &params.manage_action else {
1427                    return missing_param("manage_action", "manage");
1428                };
1429                match self
1430                    .bridge
1431                    .manage_window(params.label.as_deref(), manage_action.as_str())
1432                {
1433                    Ok(msg) => CallToolResult::success(vec![Content::text(msg)]),
1434                    Err(e) => tool_error(e),
1435                }
1436            }
1437            WindowAction::Resize => {
1438                if !self.state.privacy.is_tool_enabled("window.resize") {
1439                    return tool_disabled("window.resize");
1440                }
1441                let Some(width) = params.width else {
1442                    return missing_param("width", "resize");
1443                };
1444                let Some(height) = params.height else {
1445                    return missing_param("height", "resize");
1446                };
1447                if width == 0 || height == 0 {
1448                    return tool_error_with_hint(
1449                        format!(
1450                            "invalid window size {width}x{height}: width and height must be > 0"
1451                        ),
1452                        RecoveryHint::CheckInput,
1453                    );
1454                }
1455                match self
1456                    .bridge
1457                    .resize_window(params.label.as_deref(), width, height)
1458                {
1459                    Ok(()) => {
1460                        let result =
1461                            serde_json::json!({"ok": true, "width": width, "height": height});
1462                        CallToolResult::success(vec![Content::text(result.to_string())])
1463                    }
1464                    Err(e) => tool_error(e),
1465                }
1466            }
1467            WindowAction::MoveTo => {
1468                if !self.state.privacy.is_tool_enabled("window.move_to") {
1469                    return tool_disabled("window.move_to");
1470                }
1471                let Some(x) = params.x else {
1472                    return missing_param("x", "move_to");
1473                };
1474                let Some(y) = params.y else {
1475                    return missing_param("y", "move_to");
1476                };
1477                match self.bridge.move_window(params.label.as_deref(), x, y) {
1478                    Ok(()) => {
1479                        let result = serde_json::json!({"ok": true, "x": x, "y": y});
1480                        CallToolResult::success(vec![Content::text(result.to_string())])
1481                    }
1482                    Err(e) => tool_error(e),
1483                }
1484            }
1485            WindowAction::SetTitle => {
1486                if !self.state.privacy.is_tool_enabled("window.set_title") {
1487                    return tool_disabled("window.set_title");
1488                }
1489                let Some(title) = &params.title else {
1490                    return missing_param("title", "set_title");
1491                };
1492                match self.bridge.set_window_title(params.label.as_deref(), title) {
1493                    Ok(()) => {
1494                        let result = serde_json::json!({"ok": true, "title": title});
1495                        CallToolResult::success(vec![Content::text(result.to_string())])
1496                    }
1497                    Err(e) => tool_error(e),
1498                }
1499            }
1500        }
1501    }
1502
1503    #[tool(
1504        description = "Browser storage operations. Actions: get (read localStorage/sessionStorage), set (write), delete (remove key), get_cookies. Subject to privacy controls for set and delete.",
1505        annotations(
1506            read_only_hint = false,
1507            destructive_hint = true,
1508            idempotent_hint = false,
1509            open_world_hint = false
1510        )
1511    )]
1512    async fn storage(&self, Parameters(params): Parameters<StorageParams>) -> CallToolResult {
1513        match params.action {
1514            StorageAction::Get => {
1515                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1516                    StorageType::Session => "getSessionStorage",
1517                    StorageType::Local => "getLocalStorage",
1518                };
1519                let key_arg = params
1520                    .key
1521                    .as_ref()
1522                    .map(|k| js_string(k))
1523                    .unwrap_or_default();
1524                let code = format!("return window.__VICTAURI__?.{method}({key_arg})");
1525                self.eval_bridge(&code, params.webview_label.as_deref())
1526                    .await
1527            }
1528            StorageAction::Set => {
1529                if !self.state.privacy.is_tool_enabled("set_storage") {
1530                    return tool_disabled("set_storage");
1531                }
1532                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1533                    StorageType::Session => "setSessionStorage",
1534                    StorageType::Local => "setLocalStorage",
1535                };
1536                let Some(key) = &params.key else {
1537                    return missing_param("key", "set");
1538                };
1539                // Operator-protected keys (auth/role/tier/flags) can't be poisoned
1540                // via storage.set (audit #33).
1541                if !self.state.privacy.is_storage_key_allowed(key) {
1542                    return tool_error(format!(
1543                        "storage key '{key}' is protected by privacy configuration"
1544                    ));
1545                }
1546                let value = params
1547                    .value
1548                    .as_ref()
1549                    .cloned()
1550                    .unwrap_or(serde_json::Value::Null);
1551                let value_json =
1552                    serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
1553                let code = format!(
1554                    "return window.__VICTAURI__?.{method}({}, {value_json})",
1555                    js_string(key)
1556                );
1557                self.eval_bridge(&code, params.webview_label.as_deref())
1558                    .await
1559            }
1560            StorageAction::Delete => {
1561                if !self.state.privacy.is_tool_enabled("delete_storage") {
1562                    return tool_disabled("delete_storage");
1563                }
1564                let method = match params.storage_type.unwrap_or(StorageType::Local) {
1565                    StorageType::Session => "deleteSessionStorage",
1566                    StorageType::Local => "deleteLocalStorage",
1567                };
1568                let Some(key) = &params.key else {
1569                    return missing_param("key", "delete");
1570                };
1571                let code = format!("return window.__VICTAURI__?.{method}({})", js_string(key));
1572                self.eval_bridge(&code, params.webview_label.as_deref())
1573                    .await
1574            }
1575            StorageAction::GetCookies => {
1576                self.eval_bridge(
1577                    "return window.__VICTAURI__?.getCookies()",
1578                    params.webview_label.as_deref(),
1579                )
1580                .await
1581            }
1582        }
1583    }
1584
1585    #[tool(
1586        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.",
1587        annotations(
1588            read_only_hint = false,
1589            destructive_hint = false,
1590            idempotent_hint = false,
1591            open_world_hint = false
1592        )
1593    )]
1594    async fn navigate(&self, Parameters(params): Parameters<NavigateParams>) -> CallToolResult {
1595        match params.action {
1596            NavigateAction::GoTo => {
1597                if !self.state.privacy.is_tool_enabled("navigate") {
1598                    return tool_disabled("navigate");
1599                }
1600                let Some(url) = &params.url else {
1601                    return missing_param("url", "go_to");
1602                };
1603                if let Err(e) = validate_url(url, self.state.allow_file_navigation) {
1604                    return tool_error(e);
1605                }
1606                let code = format!("return window.__VICTAURI__?.navigate({})", js_string(url));
1607                self.eval_bridge(&code, params.webview_label.as_deref())
1608                    .await
1609            }
1610            NavigateAction::GoBack => {
1611                self.eval_bridge(
1612                    "return window.__VICTAURI__?.navigateBack()",
1613                    params.webview_label.as_deref(),
1614                )
1615                .await
1616            }
1617            NavigateAction::GetHistory => {
1618                self.eval_bridge(
1619                    "return window.__VICTAURI__?.getNavigationLog()",
1620                    params.webview_label.as_deref(),
1621                )
1622                .await
1623            }
1624            NavigateAction::SetDialogResponse => {
1625                if !self.state.privacy.is_tool_enabled("set_dialog_response") {
1626                    return tool_disabled("set_dialog_response");
1627                }
1628                let Some(dialog_type) = params.dialog_type else {
1629                    return missing_param("dialog_type", "set_dialog_response");
1630                };
1631                let Some(dialog_action) = params.dialog_action else {
1632                    return missing_param("dialog_action", "set_dialog_response");
1633                };
1634                let text_arg = params
1635                    .text
1636                    .as_ref()
1637                    .map_or_else(|| "undefined".to_string(), |t| js_string(t));
1638                let code = format!(
1639                    "return window.__VICTAURI__?.setDialogAutoResponse({}, {}, {text_arg})",
1640                    js_string(dialog_type.as_str()),
1641                    js_string(dialog_action.as_str())
1642                );
1643                self.eval_bridge(&code, params.webview_label.as_deref())
1644                    .await
1645            }
1646            NavigateAction::GetDialogLog => {
1647                self.eval_bridge(
1648                    "return window.__VICTAURI__?.getDialogLog()",
1649                    params.webview_label.as_deref(),
1650                )
1651                .await
1652            }
1653        }
1654    }
1655
1656    #[tool(
1657        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).",
1658        annotations(
1659            read_only_hint = false,
1660            destructive_hint = false,
1661            idempotent_hint = false,
1662            open_world_hint = false
1663        )
1664    )]
1665    async fn recording(&self, Parameters(params): Parameters<RecordingParams>) -> CallToolResult {
1666        const MAX_SESSION_JSON: usize = 10 * 1024 * 1024;
1667        self.track_tool_call();
1668        if !self.state.privacy.is_tool_enabled("recording") {
1669            return tool_disabled("recording");
1670        }
1671        match params.action {
1672            RecordingAction::Start => {
1673                let session_id = params
1674                    .session_id
1675                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1676                match self.state.recorder.start(session_id.clone()) {
1677                    Ok(()) => {
1678                        let result = serde_json::json!({
1679                            "started": true,
1680                            "session_id": session_id,
1681                        });
1682                        CallToolResult::success(vec![Content::text(result.to_string())])
1683                    }
1684                    Err(e) => tool_error(e.to_string()),
1685                }
1686            }
1687            RecordingAction::Stop => match self.state.recorder.stop() {
1688                Some(session) => json_result(&session),
1689                None => tool_error("no recording is active"),
1690            },
1691            RecordingAction::Checkpoint => {
1692                let Some(id) = params.checkpoint_id else {
1693                    return missing_param("checkpoint_id", "checkpoint");
1694                };
1695                let state = params.state.unwrap_or(serde_json::Value::Null);
1696                match self
1697                    .state
1698                    .recorder
1699                    .checkpoint(id.clone(), params.checkpoint_label, state)
1700                {
1701                    Ok(()) => {
1702                        let result = serde_json::json!({
1703                            "created": true,
1704                            "checkpoint_id": id,
1705                            "event_index": self.state.recorder.event_count(),
1706                        });
1707                        CallToolResult::success(vec![Content::text(result.to_string())])
1708                    }
1709                    Err(e) => tool_error(e.to_string()),
1710                }
1711            }
1712            RecordingAction::ListCheckpoints => {
1713                let checkpoints = self.state.recorder.get_checkpoints();
1714                json_result(&checkpoints)
1715            }
1716            RecordingAction::GetEvents => {
1717                let events = self
1718                    .state
1719                    .recorder
1720                    .events_since(params.since_index.unwrap_or(0));
1721                json_result(&events)
1722            }
1723            RecordingAction::EventsBetween => {
1724                let Some(from) = &params.from else {
1725                    return missing_param("from", "events_between");
1726                };
1727                let Some(to) = &params.to else {
1728                    return missing_param("to", "events_between");
1729                };
1730                match self.state.recorder.events_between_checkpoints(from, to) {
1731                    Ok(events) => json_result(&events),
1732                    Err(e) => tool_error(e.to_string()),
1733                }
1734            }
1735            RecordingAction::GetReplay => {
1736                let calls = self.state.recorder.ipc_replay_sequence();
1737                json_result(&calls)
1738            }
1739            RecordingAction::Export => match self.state.recorder.export() {
1740                Some(s) => {
1741                    let json = serde_json::to_string_pretty(&s)
1742                        .unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"));
1743                    CallToolResult::success(vec![Content::text(json)])
1744                }
1745                None => tool_error("no recording is active — start one first"),
1746            },
1747            RecordingAction::Import => {
1748                let Some(session_json) = &params.session_json else {
1749                    return missing_param("session_json", "import");
1750                };
1751                if session_json.len() > MAX_SESSION_JSON {
1752                    return tool_error("session JSON exceeds maximum size (10 MB)");
1753                }
1754                let session: victauri_core::RecordedSession =
1755                    match serde_json::from_str(session_json) {
1756                        Ok(s) => s,
1757                        Err(e) => return tool_error(format!("invalid session JSON: {e}")),
1758                    };
1759
1760                let result = serde_json::json!({
1761                    "imported": true,
1762                    "session_id": session.id,
1763                    "event_count": session.events.len(),
1764                    "checkpoint_count": session.checkpoints.len(),
1765                    "started_at": session.started_at.to_rfc3339(),
1766                });
1767                self.state.recorder.import(session);
1768                CallToolResult::success(vec![Content::text(result.to_string())])
1769            }
1770            RecordingAction::Flush => {
1771                if !self.state.recorder.is_recording() {
1772                    return tool_error("no active recording — start a recording first");
1773                }
1774                let code = "return window.__VICTAURI__?.getEventStream(0)";
1775                match self
1776                    .eval_with_return(code, params.webview_label.as_deref())
1777                    .await
1778                {
1779                    Ok(result_str) => {
1780                        let events: Vec<serde_json::Value> =
1781                            serde_json::from_str(&result_str).unwrap_or_default();
1782                        let mut count = 0u64;
1783                        for ev in &events {
1784                            if let Some(app_event) = crate::mcp::server::parse_bridge_event(ev) {
1785                                self.state.event_log.push(app_event.clone());
1786                                self.state.recorder.record_event(app_event);
1787                                count += 1;
1788                            }
1789                        }
1790                        json_result(&serde_json::json!({
1791                            "flushed": true,
1792                            "events_captured": count,
1793                        }))
1794                    }
1795                    Err(e) => tool_error(format!("flush failed: {e}")),
1796                }
1797            }
1798            RecordingAction::Replay => {
1799                let calls = self.state.recorder.ipc_replay_sequence();
1800                if calls.is_empty() {
1801                    return tool_error("no IPC calls recorded — record a session first");
1802                }
1803                let mut replay_results = Vec::new();
1804                for call in &calls {
1805                    // Enforce the same command allow/blocklist as invoke_command
1806                    // (audit #30/#31): a recorded/imported session must not be able to
1807                    // invoke a command an operator blocked.
1808                    if !self.state.privacy.is_invoke_allowed(&call.command)
1809                        || !self.state.privacy.is_command_allowed(&call.command)
1810                    {
1811                        replay_results.push(serde_json::json!({
1812                            "command": call.command,
1813                            "status": "blocked",
1814                            "error": "blocked by privacy configuration",
1815                        }));
1816                        continue;
1817                    }
1818                    let code = format!(
1819                        "return window.__TAURI_INTERNALS__.invoke({})",
1820                        js_string(&call.command)
1821                    );
1822                    let outcome = match self
1823                        .eval_with_return(&code, params.webview_label.as_deref())
1824                        .await
1825                    {
1826                        Ok(result_str) => {
1827                            let value: serde_json::Value = serde_json::from_str(&result_str)
1828                                .unwrap_or(serde_json::Value::String(result_str));
1829                            let shape = crate::introspection::JsonShape::from_value(&value);
1830                            serde_json::json!({
1831                                "command": call.command,
1832                                "status": "ok",
1833                                "response_type": shape.type_name(),
1834                            })
1835                        }
1836                        Err(e) => {
1837                            serde_json::json!({
1838                                "command": call.command,
1839                                "status": "error",
1840                                "error": e,
1841                            })
1842                        }
1843                    };
1844                    replay_results.push(outcome);
1845                }
1846                let passed = replay_results
1847                    .iter()
1848                    .filter(|r| r.get("status").and_then(|s| s.as_str()) == Some("ok"))
1849                    .count();
1850                let result = serde_json::json!({
1851                    "replayed": replay_results.len(),
1852                    "passed": passed,
1853                    "failed": replay_results.len() - passed,
1854                    "results": replay_results,
1855                });
1856                json_result(&result)
1857            }
1858        }
1859    }
1860
1861    #[tool(
1862        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).",
1863        annotations(
1864            read_only_hint = true,
1865            destructive_hint = false,
1866            idempotent_hint = true,
1867            open_world_hint = false
1868        )
1869    )]
1870    async fn inspect(&self, Parameters(params): Parameters<InspectParams>) -> CallToolResult {
1871        match params.action {
1872            InspectAction::GetStyles => {
1873                let Some(ref_id) = &params.ref_id else {
1874                    return missing_param("ref_id", "get_styles");
1875                };
1876                let props_arg = match &params.properties {
1877                    Some(props) => {
1878                        let arr: Vec<String> = props.iter().map(|p| js_string(p)).collect();
1879                        format!("[{}]", arr.join(","))
1880                    }
1881                    None => "null".to_string(),
1882                };
1883                let code = format!(
1884                    "return window.__VICTAURI__?.getStyles({}, {})",
1885                    js_string(ref_id),
1886                    props_arg
1887                );
1888                self.eval_bridge(&code, params.webview_label.as_deref())
1889                    .await
1890            }
1891            InspectAction::GetBoundingBoxes => {
1892                let Some(ref_ids) = &params.ref_ids else {
1893                    return missing_param("ref_ids", "get_bounding_boxes");
1894                };
1895                let refs: Vec<String> = ref_ids.iter().map(|r| js_string(r)).collect();
1896                let code = format!(
1897                    "return window.__VICTAURI__?.getBoundingBoxes([{}])",
1898                    refs.join(",")
1899                );
1900                self.eval_bridge(&code, params.webview_label.as_deref())
1901                    .await
1902            }
1903            InspectAction::Highlight => {
1904                let Some(ref_id) = &params.ref_id else {
1905                    return missing_param("ref_id", "highlight");
1906                };
1907                let color_arg = match &params.color {
1908                    Some(c) => match sanitize_css_color(c) {
1909                        Ok(safe) => format!("\"{safe}\""),
1910                        Err(e) => return tool_error(e),
1911                    },
1912                    None => "null".to_string(),
1913                };
1914                let label_arg = match &params.label {
1915                    Some(l) => js_string(l),
1916                    None => "null".to_string(),
1917                };
1918                let code = format!(
1919                    "return window.__VICTAURI__?.highlightElement({}, {}, {})",
1920                    js_string(ref_id),
1921                    color_arg,
1922                    label_arg
1923                );
1924                self.eval_bridge(&code, params.webview_label.as_deref())
1925                    .await
1926            }
1927            InspectAction::ClearHighlights => {
1928                self.eval_bridge(
1929                    "return window.__VICTAURI__?.clearHighlights()",
1930                    params.webview_label.as_deref(),
1931                )
1932                .await
1933            }
1934            InspectAction::AuditAccessibility => {
1935                self.eval_bridge(
1936                    "return window.__VICTAURI__?.auditAccessibility()",
1937                    params.webview_label.as_deref(),
1938                )
1939                .await
1940            }
1941            InspectAction::GetPerformance => {
1942                self.eval_bridge(
1943                    "return window.__VICTAURI__?.getPerformanceMetrics()",
1944                    params.webview_label.as_deref(),
1945                )
1946                .await
1947            }
1948        }
1949    }
1950
1951    #[tool(
1952        description = "CSS injection. Actions: inject (add custom CSS to page), remove (remove previously injected CSS). Subject to privacy controls.",
1953        annotations(
1954            read_only_hint = false,
1955            destructive_hint = false,
1956            idempotent_hint = true,
1957            open_world_hint = false
1958        )
1959    )]
1960    async fn css(&self, Parameters(params): Parameters<CssParams>) -> CallToolResult {
1961        match params.action {
1962            CssAction::Inject => {
1963                if !self.state.privacy.is_tool_enabled("inject_css") {
1964                    return tool_disabled("inject_css");
1965                }
1966                let Some(css) = &params.css else {
1967                    return missing_param("css", "inject");
1968                };
1969                // Block remote @import / url(...) exfil vectors unless explicitly opted in.
1970                if let Err(e) = sanitize_injected_css(css, params.allow_remote) {
1971                    return tool_error(e);
1972                }
1973                let code = format!("return window.__VICTAURI__?.injectCss({})", js_string(css));
1974                self.eval_bridge(&code, params.webview_label.as_deref())
1975                    .await
1976            }
1977            CssAction::Remove => {
1978                if !self.state.privacy.is_tool_enabled("css.remove") {
1979                    return tool_disabled("css.remove");
1980                }
1981                self.eval_bridge(
1982                    "return window.__VICTAURI__?.removeInjectedCss()",
1983                    params.webview_label.as_deref(),
1984                )
1985                .await
1986            }
1987        }
1988    }
1989
1990    #[tool(
1991        description = "Network request interception (Playwright route() equivalent, no CDP). \
1992            Matches webview fetch/XHR by URL and blocks, mocks, or delays them. \
1993            Actions:\n\
1994            - `add`: add a rule. `pattern` (+ optional `match_type`: substring/glob/regex/exact, \
1995              and `method`) selects requests; `behavior` is `block` (abort), `fulfill` (return a \
1996              mock `status`/`headers`/`body`/`content_type`), or `delay` (proceed after `delay_ms`). \
1997              `times` limits how often it fires. Rules are page-scoped (cleared on reload).\n\
1998            - `list`: list active rules.\n\
1999            - `clear` (by `id`) / `clear_all`: remove rules.\n\
2000            - `matches`: log of intercepted requests.\n\
2001            Note: fetch supports all behaviors; XHR supports block/delay (fulfill is fetch-only). \
2002            Top-level navigation, sub-resource (img/css), and WebSocket traffic are not intercepted. \
2003            Tauri IPC (ipc.localhost) is OBSERVE-ONLY: such calls appear in `matches`, but block/\
2004            fulfill/delay do NOT take effect on them — Tauri serves IPC below the JS fetch layer, so \
2005            it cannot be controlled cross-platform without CDP. There is no IPC-control tool; the \
2006            `fault` tool only affects commands you drive via `invoke_command`, not real user IPC.",
2007        annotations(
2008            read_only_hint = false,
2009            destructive_hint = false,
2010            idempotent_hint = false,
2011            open_world_hint = false
2012        )
2013    )]
2014    async fn route(&self, Parameters(params): Parameters<RouteParams>) -> CallToolResult {
2015        self.track_tool_call();
2016        match params.action {
2017            RouteAction::Add => {
2018                if !self.state.privacy.is_tool_enabled("route.add") {
2019                    return tool_disabled("route.add");
2020                }
2021                let Some(pattern) = &params.pattern else {
2022                    return missing_param("pattern", "add");
2023                };
2024                let behavior = params.behavior.unwrap_or(RouteBehavior::Fulfill);
2025                let match_type = params.match_type.unwrap_or(RouteMatchType::Substring);
2026                let mut rule = serde_json::json!({
2027                    "pattern": pattern,
2028                    "match_type": match_type.as_str(),
2029                    "action": behavior.as_str(),
2030                });
2031                if let Some(m) = &params.method {
2032                    rule["method"] = serde_json::json!(m);
2033                }
2034                if let Some(s) = params.status {
2035                    rule["status"] = serde_json::json!(s);
2036                }
2037                if let Some(st) = &params.status_text {
2038                    rule["status_text"] = serde_json::json!(st);
2039                }
2040                if let Some(h) = &params.headers {
2041                    rule["headers"] = h.clone();
2042                }
2043                if let Some(b) = &params.body {
2044                    // A JSON string body is passed through as-is; structured JSON
2045                    // is stringified so the bridge sends valid JSON text.
2046                    rule["body"] = match b {
2047                        serde_json::Value::String(s) => serde_json::json!(s),
2048                        other => serde_json::json!(other.to_string()),
2049                    };
2050                }
2051                if let Some(ct) = &params.content_type {
2052                    rule["content_type"] = serde_json::json!(ct);
2053                }
2054                if let Some(d) = params.delay_ms {
2055                    rule["delay_ms"] = serde_json::json!(d);
2056                }
2057                if let Some(t) = params.times {
2058                    rule["times"] = serde_json::json!(t);
2059                }
2060                let code = format!(
2061                    "return window.__VICTAURI__?.addRoute({})",
2062                    js_string(&rule.to_string())
2063                );
2064                self.eval_bridge(&code, params.webview_label.as_deref())
2065                    .await
2066            }
2067            RouteAction::List => {
2068                self.eval_bridge(
2069                    "return window.__VICTAURI__?.getRouteRules()",
2070                    params.webview_label.as_deref(),
2071                )
2072                .await
2073            }
2074            RouteAction::Clear => {
2075                let Some(id) = params.id else {
2076                    return missing_param("id", "clear");
2077                };
2078                let code = format!("return window.__VICTAURI__?.clearRoute({id})");
2079                self.eval_bridge(&code, params.webview_label.as_deref())
2080                    .await
2081            }
2082            RouteAction::ClearAll => {
2083                self.eval_bridge(
2084                    "return window.__VICTAURI__?.clearRoutes()",
2085                    params.webview_label.as_deref(),
2086                )
2087                .await
2088            }
2089            RouteAction::Matches => {
2090                let limit = params.limit.unwrap_or(100);
2091                let code = format!("return window.__VICTAURI__?.getRouteMatches({limit})");
2092                self.eval_bridge(&code, params.webview_label.as_deref())
2093                    .await
2094            }
2095        }
2096    }
2097
2098    #[tool(
2099        description = "Screencast / visual trace (no CDP). Captures the window at a fixed interval \
2100            into a ring buffer, forming a visual timeline that pairs with `recording` (events) and \
2101            `logs` (network/console). Actions:\n\
2102            - `start`: begin capturing (`interval_ms` default 500, `max_frames` default 60). Set \
2103              `with_events=true` to also start the event recorder.\n\
2104            - `stop`: stop and return a summary (frame count, duration, timestamps).\n\
2105            - `status`: active flag + buffered frame count.\n\
2106            - `frames`: return captured frames as base64 PNGs (`limit` caps how many).",
2107        annotations(
2108            read_only_hint = false,
2109            destructive_hint = false,
2110            idempotent_hint = false,
2111            open_world_hint = false
2112        )
2113    )]
2114    async fn trace(&self, Parameters(params): Parameters<TraceParams>) -> CallToolResult {
2115        self.track_tool_call();
2116        if !self.state.privacy.is_tool_enabled("trace")
2117            || !self.state.privacy.is_tool_enabled("screenshot")
2118        {
2119            return tool_disabled("trace");
2120        }
2121        match params.action {
2122            TraceAction::Start => {
2123                let interval = params.interval_ms.unwrap_or(500);
2124                let max_frames = params.max_frames.unwrap_or(60);
2125                let label = params.webview_label.clone();
2126                let generation = self
2127                    .state
2128                    .screencast
2129                    .start(interval, max_frames, label.clone());
2130
2131                let mut events_started = false;
2132                if params.with_events.unwrap_or(false) {
2133                    let session_id = uuid::Uuid::new_v4().to_string();
2134                    if self.state.recorder.start(session_id).is_ok() {
2135                        events_started = true;
2136                    }
2137                }
2138
2139                // Background capture task: snapshot the window each interval until
2140                // the screencast is stopped (or superseded by a newer start).
2141                let bridge = self.bridge.clone();
2142                let screencast = self.state.screencast.clone();
2143                tokio::spawn(async move {
2144                    let t0 = std::time::Instant::now();
2145                    while screencast.is_active() && screencast.generation() == generation {
2146                        if let Ok(handle) = bridge.get_native_handle(label.as_deref())
2147                            && let Ok(png) = crate::screenshot::capture_window(handle).await
2148                        {
2149                            use base64::Engine;
2150                            let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2151                            #[allow(clippy::cast_possible_truncation)]
2152                            screencast.push_frame(t0.elapsed().as_millis() as u64, b64);
2153                        }
2154                        tokio::time::sleep(std::time::Duration::from_millis(
2155                            screencast.interval_ms(),
2156                        ))
2157                        .await;
2158                    }
2159                });
2160
2161                json_result(&serde_json::json!({
2162                    "started": true,
2163                    "interval_ms": interval.max(50),
2164                    "max_frames": max_frames.clamp(1, 600),
2165                    "with_events": events_started,
2166                }))
2167            }
2168            TraceAction::Stop => {
2169                let frame_count = self.state.screencast.stop();
2170                let timestamps = self.state.screencast.frame_timestamps();
2171                let duration_ms = timestamps.last().copied().unwrap_or(0);
2172                let event_count = self.state.recorder.event_count();
2173                json_result(&serde_json::json!({
2174                    "stopped": true,
2175                    "frame_count": frame_count,
2176                    "duration_ms": duration_ms,
2177                    "frame_timestamps_ms": timestamps,
2178                    "recorded_event_count": event_count,
2179                    "hint": "use action=frames to retrieve PNGs; pair with recording/get_events and logs for a full bundle",
2180                }))
2181            }
2182            TraceAction::Status => json_result(&serde_json::json!({
2183                "active": self.state.screencast.is_active(),
2184                "frame_count": self.state.screencast.frame_count(),
2185                "interval_ms": self.state.screencast.interval_ms(),
2186            })),
2187            TraceAction::Frames => {
2188                let limit = params.limit.unwrap_or(0);
2189                let frames = self.state.screencast.frames(limit);
2190                let items: Vec<Content> = frames
2191                    .into_iter()
2192                    .map(|f| Content::image(f.data_b64, "image/png"))
2193                    .collect();
2194                if items.is_empty() {
2195                    return json_result(&serde_json::json!({ "frames": 0 }));
2196                }
2197                CallToolResult::success(items)
2198            }
2199        }
2200    }
2201
2202    #[tool(
2203        description = "Animation introspection (no CDP). Reads the Web Animations API to reveal what \
2204            the webview's animation engine is actually running — duration, delay, easing, iterations, \
2205            keyframes, current progress, and the animating element. Standard DOM, so it works \
2206            identically on WebView2/WKWebView/WebKitGTK. Actions:\n\
2207            - `list`: return all running CSS animations/transitions (optionally scoped by `selector`), \
2208              each with declared `timing`, `computed` progress, `keyframes`, and `target`.\n\
2209            - `scrub`: deterministically pause the target's animation and seek it to `points` \
2210              evenly-spaced steps (default 20), returning the exact geometry curve (rect + transform \
2211              + opacity per step). With `capture=true`, also returns a single contact-sheet filmstrip \
2212              PNG (one image of the whole arc) plus a `manifest` mapping each cell to its progress/time. \
2213              Frozen frames are jank-free, so this beats real-time capture for fast sweeps. CSS-driven \
2214              animations only (JS/rAF animations are not seekable — use `list`/`sample`).\n\
2215            - `sample`: real-time motion recorder. `record=true` arms a requestAnimationFrame watcher \
2216              on `selector` (or the first animating element); then trigger the animation; then call \
2217              with `record=false` to read the measured per-frame curve plus jank stats (dropped frames, \
2218              max frame gap) and declared-vs-measured duration. Works for ANY animation including \
2219              JS/rAF-driven ones. `clear=true` resets recorded sessions.\n\
2220            NOTE: an animation only appears while it is running or pending — trigger it (e.g. show the \
2221            notification) just before calling `list`/`scrub`, or arm `sample` before triggering.",
2222        annotations(
2223            read_only_hint = true,
2224            destructive_hint = false,
2225            idempotent_hint = true,
2226            open_world_hint = false
2227        )
2228    )]
2229    async fn animation(&self, Parameters(params): Parameters<AnimationParams>) -> CallToolResult {
2230        self.track_tool_call();
2231        if !self.state.privacy.is_tool_enabled("animation") {
2232            return tool_disabled("animation");
2233        }
2234        match params.action {
2235            AnimationAction::List => {
2236                let sel = params
2237                    .selector
2238                    .as_deref()
2239                    .map_or_else(|| "null".to_string(), js_string);
2240                let code = format!(
2241                    "return window.__VICTAURI__ && window.__VICTAURI__.listAnimations({sel})"
2242                );
2243                match self
2244                    .eval_with_return(&code, params.webview_label.as_deref())
2245                    .await
2246                {
2247                    Ok(result_str) => {
2248                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2249                            Ok(v) => json_result(&v),
2250                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2251                        }
2252                    }
2253                    Err(e) => tool_error(format!("animation list failed: {e}")),
2254                }
2255            }
2256            AnimationAction::Scrub => self.animation_scrub(params).await,
2257            AnimationAction::Sample => {
2258                let label = params.webview_label.as_deref();
2259                let sel = params
2260                    .selector
2261                    .as_deref()
2262                    .map_or_else(|| "null".to_string(), js_string);
2263                let code = if params.record.unwrap_or(false) {
2264                    format!("return window.__VICTAURI__.installSweepRecorder({sel})")
2265                } else {
2266                    let clear = params.clear.unwrap_or(false);
2267                    format!("return window.__VICTAURI__.readSweep({clear})")
2268                };
2269                match self.eval_with_return(&code, label).await {
2270                    Ok(result_str) => {
2271                        match serde_json::from_str::<serde_json::Value>(&result_str) {
2272                            Ok(v) => json_result(&v),
2273                            Err(_) => CallToolResult::success(vec![Content::text(result_str)]),
2274                        }
2275                    }
2276                    Err(e) => tool_error(format!("animation sample failed: {e}")),
2277                }
2278            }
2279        }
2280    }
2281
2282    /// Deterministic pause-seek-capture loop for `animation scrub`. Split out to
2283    /// keep the `#[tool]` method readable.
2284    async fn animation_scrub(&self, params: AnimationParams) -> CallToolResult {
2285        let label = params.webview_label.as_deref();
2286        let sel = params
2287            .selector
2288            .as_deref()
2289            .map_or_else(|| "null".to_string(), js_string);
2290
2291        // 1. Prepare: pause the target's animations, learn the timeline length.
2292        let prep_code = format!("return await window.__VICTAURI__.scrubPrepare({sel})");
2293        let prep_v = match self.eval_with_return(&prep_code, label).await {
2294            Ok(s) => {
2295                serde_json::from_str::<serde_json::Value>(&s).unwrap_or(serde_json::Value::Null)
2296            }
2297            Err(e) => return tool_error(format!("scrub prepare failed: {e}")),
2298        };
2299        if prep_v.get("prepared").and_then(serde_json::Value::as_bool) != Some(true) {
2300            // Surface the helpful error/info object (no target, JS-driven, etc.).
2301            return json_result(&prep_v);
2302        }
2303
2304        let points = params.points.unwrap_or(20).clamp(2, 120);
2305        let capture = params.capture.unwrap_or(false);
2306        let mut curve: Vec<serde_json::Value> = Vec::with_capacity(points);
2307        let mut frames: Vec<crate::filmstrip::Frame> = Vec::new();
2308        let mut manifest: Vec<serde_json::Value> = Vec::new();
2309
2310        // 2. Seek to each evenly-spaced point; capture the frozen frame if asked.
2311        for i in 0..points {
2312            #[allow(clippy::cast_precision_loss)]
2313            let progress = i as f64 / (points - 1) as f64;
2314            let seek_code = format!("return await window.__VICTAURI__.scrubSeek({progress})");
2315            match self.eval_with_return(&seek_code, label).await {
2316                Ok(s) => {
2317                    let v = serde_json::from_str::<serde_json::Value>(&s)
2318                        .unwrap_or(serde_json::Value::Null);
2319                    if capture
2320                        && let Ok(handle) = self.bridge.get_native_handle(label)
2321                        && let Ok((rgba, w, h)) =
2322                            crate::screenshot::capture_window_raw(handle).await
2323                        && let Some(frame) = crate::filmstrip::Frame::new(rgba, w, h)
2324                    {
2325                        manifest.push(serde_json::json!({
2326                            "cell": frames.len(),
2327                            "progress": progress,
2328                            "t": v.get("t").cloned().unwrap_or(serde_json::Value::Null),
2329                        }));
2330                        frames.push(frame);
2331                    }
2332                    curve.push(v);
2333                }
2334                Err(e) => curve.push(serde_json::json!({ "progress": progress, "error": e })),
2335            }
2336        }
2337
2338        // 3. Restore (resume) or leave paused.
2339        let resume = params.restore.unwrap_or(true);
2340        let restore_code = format!("return window.__VICTAURI__.scrubRestore({resume})");
2341        let _ = self.eval_with_return(&restore_code, label).await;
2342
2343        let mut meta = serde_json::json!({
2344            "scrubbed": true,
2345            "points": points,
2346            "duration_ms": prep_v.get("duration").cloned().unwrap_or(serde_json::Value::Null),
2347            "anim_count": prep_v.get("anim_count").cloned().unwrap_or(serde_json::Value::Null),
2348            "target": prep_v.get("target").cloned().unwrap_or(serde_json::Value::Null),
2349            "captured": capture,
2350            "curve": curve,
2351        });
2352
2353        // 4. Compose the filmstrip if we captured frames.
2354        if capture && !frames.is_empty() {
2355            let cols = params
2356                .cols
2357                .unwrap_or_else(|| crate::filmstrip::default_cols(frames.len()));
2358            if let Some((rgba, w, h)) =
2359                crate::filmstrip::compose(&frames, cols, 4, [20, 20, 20, 255])
2360            {
2361                match crate::screenshot::encode_png(w, h, &rgba) {
2362                    Ok(png) => {
2363                        use base64::Engine;
2364                        let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
2365                        meta["filmstrip"] = serde_json::json!({
2366                            "cols": cols,
2367                            "frame_count": frames.len(),
2368                            "width": w,
2369                            "height": h,
2370                            "manifest": manifest,
2371                        });
2372                        return CallToolResult::success(vec![
2373                            Content::image(b64, "image/png"),
2374                            Content::text(meta.to_string()),
2375                        ]);
2376                    }
2377                    Err(e) => return tool_error(format!("filmstrip encode failed: {e}")),
2378                }
2379            }
2380        }
2381
2382        json_result(&meta)
2383    }
2384
2385    #[tool(
2386        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).",
2387        annotations(
2388            read_only_hint = true,
2389            destructive_hint = false,
2390            idempotent_hint = true,
2391            open_world_hint = false
2392        )
2393    )]
2394    async fn logs(&self, Parameters(params): Parameters<LogsParams>) -> CallToolResult {
2395        match params.action {
2396            LogsAction::Console => {
2397                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2398                let base = if since_arg.is_empty() {
2399                    "window.__VICTAURI__?.getConsoleLogs()".to_string()
2400                } else {
2401                    format!("window.__VICTAURI__?.getConsoleLogs({since_arg})")
2402                };
2403                let code = if let Some(limit) = params.limit {
2404                    format!("return ({base} || []).slice(-{limit})")
2405                } else {
2406                    format!("return {base}")
2407                };
2408                self.eval_bridge(&code, params.webview_label.as_deref())
2409                    .await
2410            }
2411            LogsAction::Network => {
2412                let filter_arg = params
2413                    .filter
2414                    .as_ref()
2415                    .map_or_else(|| "null".to_string(), |f| js_string(f));
2416                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2417                let source = format!("window.__VICTAURI__?.getNetworkLog({filter_arg}, {limit})");
2418                let code = trimmed_log_js(&source, limit);
2419                self.eval_bridge(&code, params.webview_label.as_deref())
2420                    .await
2421            }
2422            LogsAction::Ipc => {
2423                let wait = params.wait_for_capture.unwrap_or(false);
2424                let limit = params.limit.unwrap_or(DEFAULT_LOG_LIMIT);
2425                if wait {
2426                    let inner = trimmed_log_js("window.__VICTAURI__.getIpcLog()", limit);
2427                    let code = format!(
2428                        r"return (async function() {{
2429                            await window.__VICTAURI__.waitForIpcComplete(500);
2430                            return (function() {{ {inner} }})();
2431                        }})()"
2432                    );
2433                    let timeout = std::time::Duration::from_millis(5000);
2434                    match self
2435                        .eval_with_return_timeout(&code, params.webview_label.as_deref(), timeout)
2436                        .await
2437                    {
2438                        Ok(result) => CallToolResult::success(vec![Content::text(result)]),
2439                        Err(e) => tool_error(e),
2440                    }
2441                } else {
2442                    let code = trimmed_log_js("window.__VICTAURI__?.getIpcLog()", limit);
2443                    self.eval_bridge(&code, params.webview_label.as_deref())
2444                        .await
2445                }
2446            }
2447            LogsAction::Navigation => {
2448                let code = if let Some(limit) = params.limit {
2449                    format!(
2450                        "return (window.__VICTAURI__?.getNavigationLog() || []).slice(-{limit})"
2451                    )
2452                } else {
2453                    "return window.__VICTAURI__?.getNavigationLog()".to_string()
2454                };
2455                self.eval_bridge(&code, params.webview_label.as_deref())
2456                    .await
2457            }
2458            LogsAction::Dialogs => {
2459                let code = if let Some(limit) = params.limit {
2460                    format!("return (window.__VICTAURI__?.getDialogLog() || []).slice(-{limit})")
2461                } else {
2462                    "return window.__VICTAURI__?.getDialogLog()".to_string()
2463                };
2464                self.eval_bridge(&code, params.webview_label.as_deref())
2465                    .await
2466            }
2467            LogsAction::Events => {
2468                let since_arg = params.since.map(|ts| format!("{ts}")).unwrap_or_default();
2469                let base = if since_arg.is_empty() {
2470                    "window.__VICTAURI__?.getEventStream()".to_string()
2471                } else {
2472                    format!("window.__VICTAURI__?.getEventStream({since_arg})")
2473                };
2474                let code = if let Some(limit) = params.limit {
2475                    format!("return ({base} || []).slice(-{limit})")
2476                } else {
2477                    format!("return {base}")
2478                };
2479                self.eval_bridge(&code, params.webview_label.as_deref())
2480                    .await
2481            }
2482            LogsAction::SlowIpc => {
2483                let Some(threshold) = params.threshold_ms else {
2484                    return missing_param("threshold_ms", "slow_ipc");
2485                };
2486                let limit = params.limit.unwrap_or(20);
2487                let mb = MAX_LOG_FIELD_BYTES;
2488                let code = format!(
2489                    r"return (function() {{
2490                        var MB = {mb};
2491                        function trimField(v) {{
2492                            if (typeof v === 'string') return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
2493                            if (v && typeof v === 'object') {{ var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }} if (s.length > MB) return '[truncated ' + s.length + ' bytes]'; }}
2494                            return v;
2495                        }}
2496                        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; }}
2497                        var log = window.__VICTAURI__?.getIpcLog() || [];
2498                        var slow = log.filter(function(c) {{ return (c.duration_ms || 0) > {threshold}; }});
2499                        slow.sort(function(a, b) {{ return (b.duration_ms || 0) - (a.duration_ms || 0); }});
2500                        return {{ threshold_ms: {threshold}, count: Math.min(slow.length, {limit}), calls: slow.slice(0, {limit}).map(trimEntry) }};
2501                    }})()",
2502                );
2503                self.eval_bridge(&code, None).await
2504            }
2505            LogsAction::Clear => {
2506                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'] }; })()";
2507                self.eval_bridge(code, params.webview_label.as_deref())
2508                    .await
2509            }
2510        }
2511    }
2512
2513    // ── Backend Introspection ────────────────────────────────────────────────
2514
2515    #[tool(
2516        description = "Deep backend introspection — command profiling, IPC contract testing, \
2517            coverage, startup timing, capability auditing, database diagnostics, process \
2518            enumeration, and event bus monitoring. \
2519            These features exploit Victauri's position inside the Rust process.\n\n\
2520            Actions:\n\
2521            - `command_timings`: Per-command execution timing stats (min/max/avg/p95). Set `slow_threshold_ms` to filter.\n\
2522            - `coverage`: Which registered commands have been called during this session.\n\
2523            - `contract_record`: Record a command's response shape as a baseline (requires `command`).\n\
2524            - `contract_check`: Check all recorded contracts for schema drift.\n\
2525            - `contract_list`: List all recorded contract baselines.\n\
2526            - `contract_clear`: Clear all recorded contract baselines.\n\
2527            - `startup_timing`: Victauri plugin initialization phase-by-phase timing breakdown.\n\
2528            - `capabilities`: Enumerate Tauri v2 capabilities, security config (CSP, freeze_prototype), configured plugins, and window definitions.\n\
2529            - `db_health`: SQLite database diagnostics (journal mode, WAL, page stats).\n\
2530            - `plugin_state`: Snapshot of the Victauri plugin's internal state (event log, registry, faults, recording, timings, etc.).\n\
2531            - `processes`: Enumerate the host process and all child processes (sidecars, background workers) with PID, name, and memory usage.\n\
2532            - `plugin_tasks`: List Victauri's own spawned async tasks (MCP server, event drain) with status.\n\
2533            - `event_bus`: List all captured Tauri events (automatically intercepted via listen_any — no app opt-in needed).\n\
2534            - `event_bus_clear`: Clear the event bus capture buffer.",
2535        annotations(
2536            read_only_hint = true,
2537            destructive_hint = false,
2538            idempotent_hint = true,
2539            open_world_hint = false
2540        )
2541    )]
2542    async fn introspect(&self, Parameters(params): Parameters<IntrospectParams>) -> CallToolResult {
2543        self.track_tool_call();
2544        if !self.state.privacy.is_tool_enabled("introspect") {
2545            return tool_disabled("introspect");
2546        }
2547
2548        match params.action {
2549            IntrospectAction::CommandTimings => {
2550                let mut stats = self.state.command_timings.all_stats();
2551                if let Some(threshold) = params.slow_threshold_ms {
2552                    stats.retain(|s| s.avg_ms >= threshold);
2553                }
2554                let result = serde_json::json!({
2555                    "commands": stats,
2556                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
2557                    "slow_threshold_ms": params.slow_threshold_ms,
2558                });
2559                json_result(&result)
2560            }
2561            IntrospectAction::Coverage => {
2562                let registered: Vec<String> = self
2563                    .state
2564                    .registry
2565                    .list()
2566                    .iter()
2567                    .map(|c| c.name.clone())
2568                    .collect();
2569
2570                let code = "return window.__VICTAURI__?.getIpcLog()";
2571                let invoked: std::collections::HashSet<String> = match self
2572                    .eval_with_return(code, params.webview_label.as_deref())
2573                    .await
2574                {
2575                    Ok(json_str) => {
2576                        if let Ok(entries) =
2577                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2578                        {
2579                            entries
2580                                .iter()
2581                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2582                                .map(String::from)
2583                                .collect()
2584                        } else {
2585                            std::collections::HashSet::new()
2586                        }
2587                    }
2588                    Err(_) => std::collections::HashSet::new(),
2589                };
2590
2591                let uncovered: Vec<&String> = registered
2592                    .iter()
2593                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2594                    .collect();
2595
2596                let coverage_pct = if registered.is_empty() {
2597                    100.0
2598                } else {
2599                    let covered = registered.len() - uncovered.len();
2600                    (covered as f64 / registered.len() as f64) * 100.0
2601                };
2602
2603                let result = serde_json::json!({
2604                    "registered_commands": registered.len(),
2605                    "invoked_commands": invoked.len(),
2606                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2607                    "uncovered": uncovered,
2608                    "invoked_not_registered": invoked.iter()
2609                        .filter(|cmd| !registered.contains(cmd))
2610                        .collect::<Vec<_>>(),
2611                });
2612                json_result(&result)
2613            }
2614            IntrospectAction::ContractRecord => {
2615                let Some(command) = params.command else {
2616                    return missing_param("command", "contract_record");
2617                };
2618                // contract_record invokes the command with caller-supplied args, so
2619                // it must honour the same allow/blocklist as invoke_command (audit #30).
2620                if !self.state.privacy.is_invoke_allowed(&command)
2621                    || !self.state.privacy.is_command_allowed(&command)
2622                {
2623                    return tool_error(format!(
2624                        "command '{command}' is blocked by privacy configuration"
2625                    ));
2626                }
2627                let args_json = params.args.unwrap_or(serde_json::json!({}));
2628                let args_str =
2629                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2630                let code = format!(
2631                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2632                    js_string(&command)
2633                );
2634                match self
2635                    .eval_with_return(&code, params.webview_label.as_deref())
2636                    .await
2637                {
2638                    Ok(result_str) => {
2639                        let value: serde_json::Value = serde_json::from_str(&result_str)
2640                            .unwrap_or(serde_json::Value::String(result_str.clone()));
2641                        let shape = crate::introspection::JsonShape::from_value(&value);
2642                        let sample = if result_str.len() > 4096 {
2643                            format!("{}...(truncated)", &result_str[..4096])
2644                        } else {
2645                            result_str
2646                        };
2647                        let baseline = crate::introspection::ContractBaseline {
2648                            command: command.clone(),
2649                            args: args_json,
2650                            shape: shape.clone(),
2651                            sample,
2652                            recorded_at: chrono_now(),
2653                        };
2654                        self.state.contract_store.record(baseline);
2655                        let result = serde_json::json!({
2656                            "recorded": true,
2657                            "command": command,
2658                            "shape_type": shape.type_name(),
2659                        });
2660                        json_result(&result)
2661                    }
2662                    Err(e) => tool_error(format!(
2663                        "failed to invoke '{command}' for contract recording: {e}"
2664                    )),
2665                }
2666            }
2667            IntrospectAction::ContractCheck => {
2668                let baselines = self.state.contract_store.all();
2669                if baselines.is_empty() {
2670                    return json_result(&serde_json::json!({
2671                        "checked": 0,
2672                        "message": "no contract baselines recorded — use contract_record first",
2673                    }));
2674                }
2675                let mut results = Vec::new();
2676                for baseline in &baselines {
2677                    // Re-checking a baseline re-invokes the command; honour the
2678                    // allow/blocklist in case it changed since recording (audit #30).
2679                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
2680                        || !self.state.privacy.is_command_allowed(&baseline.command)
2681                    {
2682                        continue;
2683                    }
2684                    let args_str =
2685                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2686                    let code = format!(
2687                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2688                        js_string(&baseline.command)
2689                    );
2690                    match self
2691                        .eval_with_return(&code, params.webview_label.as_deref())
2692                        .await
2693                    {
2694                        Ok(result_str) => {
2695                            let value: serde_json::Value = serde_json::from_str(&result_str)
2696                                .unwrap_or(serde_json::Value::String(result_str));
2697                            let current_shape = crate::introspection::JsonShape::from_value(&value);
2698                            let drift = crate::introspection::diff_shapes(
2699                                &baseline.shape,
2700                                &current_shape,
2701                                &baseline.command,
2702                            );
2703                            results.push(drift);
2704                        }
2705                        Err(e) => {
2706                            results.push(crate::introspection::ContractDrift {
2707                                command: baseline.command.clone(),
2708                                new_fields: Vec::new(),
2709                                removed_fields: Vec::new(),
2710                                type_changes: Vec::new(),
2711                                shape_matches: false,
2712                            });
2713                            tracing::warn!(
2714                                command = %baseline.command,
2715                                error = %e,
2716                                "contract check invocation failed"
2717                            );
2718                        }
2719                    }
2720                }
2721                let passing = results.iter().filter(|r| r.shape_matches).count();
2722                let result = serde_json::json!({
2723                    "checked": results.len(),
2724                    "passing": passing,
2725                    "failing": results.len() - passing,
2726                    "contracts": results,
2727                });
2728                json_result(&result)
2729            }
2730            IntrospectAction::ContractList => {
2731                let baselines = self.state.contract_store.all();
2732                let result = serde_json::json!({
2733                    "count": baselines.len(),
2734                    "baselines": baselines.iter().map(|b| serde_json::json!({
2735                        "command": b.command,
2736                        "shape_type": b.shape.type_name(),
2737                        "recorded_at": b.recorded_at,
2738                    })).collect::<Vec<_>>(),
2739                });
2740                json_result(&result)
2741            }
2742            IntrospectAction::ContractClear => {
2743                let cleared = self.state.contract_store.clear();
2744                json_result(&serde_json::json!({
2745                    "cleared": cleared,
2746                }))
2747            }
2748            IntrospectAction::StartupTiming => {
2749                let phases = self.state.startup_timeline.report();
2750                let result = serde_json::json!({
2751                    "phases": phases,
2752                    "total_ms": self.state.startup_timeline.total_ms(),
2753                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2754                });
2755                json_result(&result)
2756            }
2757            IntrospectAction::Capabilities => {
2758                let config = self.bridge.tauri_config();
2759                let live_windows = self.bridge.list_window_labels();
2760
2761                let result = serde_json::json!({
2762                    "app": {
2763                        "identifier": config.get("identifier"),
2764                        "product_name": config.get("product_name"),
2765                        "version": config.get("version"),
2766                    },
2767                    "security": config.get("security"),
2768                    "configured_windows": config.get("windows"),
2769                    "live_windows": live_windows,
2770                    "configured_plugins": config.get("plugins"),
2771                    "victauri": {
2772                        "registered_commands": self.state.registry.list().len(),
2773                        "redaction_enabled": self.state.privacy.redaction_enabled,
2774                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
2775                        "disabled_tools": &self.state.privacy.disabled_tools,
2776                    },
2777                });
2778                json_result(&result)
2779            }
2780            #[allow(unused_variables)]
2781            IntrospectAction::DbHealth => {
2782                #[cfg(feature = "sqlite")]
2783                {
2784                    let db_path = params.db_path.clone();
2785                    match self.run_db_health(db_path.as_deref()).await {
2786                        Ok(health) => json_result(&health),
2787                        Err(e) => tool_error(format!("db_health failed: {e}")),
2788                    }
2789                }
2790                #[cfg(not(feature = "sqlite"))]
2791                {
2792                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
2793                }
2794            }
2795            IntrospectAction::PluginState => {
2796                let recording_active = self.state.recorder.is_recording();
2797                let recording_events = self.state.recorder.event_count();
2798                let result = serde_json::json!({
2799                    "event_log": {
2800                        "size": self.state.event_log.len(),
2801                        "capacity": self.state.event_log.capacity(),
2802                    },
2803                    "registry": {
2804                        "commands_registered": self.state.registry.list().len(),
2805                    },
2806                    "recording": {
2807                        "active": recording_active,
2808                        "events_captured": recording_events,
2809                    },
2810                    "faults": {
2811                        "active_rules": self.state.fault_registry.list().len(),
2812                    },
2813                    "contracts": {
2814                        "baselines_recorded": self.state.contract_store.all().len(),
2815                    },
2816                    "timings": {
2817                        "commands_profiled": self.state.command_timings.all_stats().len(),
2818                    },
2819                    "event_bus": {
2820                        "captured_events": self.state.event_bus.len(),
2821                    },
2822                    "tasks": {
2823                        "total": self.state.task_tracker.list().len(),
2824                        "active": self.state.task_tracker.active_count(),
2825                    },
2826                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
2827                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
2828                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
2829                });
2830                json_result(&result)
2831            }
2832            IntrospectAction::Processes => {
2833                let pid = std::process::id();
2834                let uptime = self.state.started_at.elapsed();
2835                let children = crate::introspection::enumerate_child_processes();
2836                let host_memory = crate::memory::current_stats();
2837
2838                let result = serde_json::json!({
2839                    "host": {
2840                        "pid": pid,
2841                        "uptime_secs": uptime.as_secs(),
2842                        "platform": std::env::consts::OS,
2843                        "arch": std::env::consts::ARCH,
2844                        "memory": host_memory,
2845                    },
2846                    "children": children.iter().map(|c| serde_json::json!({
2847                        "pid": c.pid,
2848                        "name": c.name,
2849                        "memory_bytes": c.memory_bytes,
2850                    })).collect::<Vec<_>>(),
2851                    "child_count": children.len(),
2852                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
2853                });
2854                json_result(&result)
2855            }
2856            IntrospectAction::PluginTasks => {
2857                let tasks = self.state.task_tracker.list();
2858                let active = self.state.task_tracker.active_count();
2859                let result = serde_json::json!({
2860                    "total": tasks.len(),
2861                    "active": active,
2862                    "finished": tasks.len() - active,
2863                    "tasks": tasks,
2864                });
2865                json_result(&result)
2866            }
2867            IntrospectAction::EventBus => {
2868                let tauri_events = self.state.event_bus.events();
2869                let app_events = self.state.event_log.snapshot();
2870                let result = serde_json::json!({
2871                    "tauri_events": {
2872                        "count": tauri_events.len(),
2873                        "events": tauri_events,
2874                    },
2875                    "app_events": {
2876                        "count": app_events.len(),
2877                        "capacity": self.state.event_log.capacity(),
2878                        "events": app_events,
2879                    },
2880                });
2881                json_result(&result)
2882            }
2883            IntrospectAction::EventBusClear => {
2884                let tauri_cleared = self.state.event_bus.clear();
2885                self.state.event_log.clear();
2886                json_result(&serde_json::json!({
2887                    "tauri_events_cleared": tauri_cleared,
2888                    "app_events_cleared": true,
2889                }))
2890            }
2891        }
2892    }
2893
2894    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
2895
2896    #[tool(
2897        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
2898            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
2899            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
2900            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
2901            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
2902            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
2903            Actions:\n\
2904            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
2905            - `list`: List all active fault injection rules.\n\
2906            - `clear`: Remove a specific fault rule (requires `command`).\n\
2907            - `clear_all`: Remove all fault rules.",
2908        annotations(
2909            read_only_hint = false,
2910            destructive_hint = true,
2911            idempotent_hint = false,
2912            open_world_hint = false
2913        )
2914    )]
2915    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
2916        self.track_tool_call();
2917        if !self.state.privacy.is_tool_enabled("fault") {
2918            return tool_disabled("fault");
2919        }
2920
2921        match params.action {
2922            FaultAction::Inject => {
2923                let Some(command) = params.command else {
2924                    return missing_param("command", "inject");
2925                };
2926                let Some(fault_kind) = params.fault_type else {
2927                    return missing_param("fault_type", "inject");
2928                };
2929                let fault_type = match fault_kind {
2930                    FaultKind::Delay => {
2931                        let delay_ms = params.delay_ms.unwrap_or(1000);
2932                        crate::introspection::FaultType::Delay { delay_ms }
2933                    }
2934                    FaultKind::Error => {
2935                        let message = params
2936                            .error_message
2937                            .unwrap_or_else(|| "injected fault".to_string());
2938                        crate::introspection::FaultType::Error { message }
2939                    }
2940                    FaultKind::Drop => crate::introspection::FaultType::Drop,
2941                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
2942                };
2943                let config = crate::introspection::FaultConfig {
2944                    command: command.clone(),
2945                    fault_type: fault_type.clone(),
2946                    trigger_count: 0,
2947                    max_triggers: params.max_triggers.unwrap_or(0),
2948                    created_at: std::time::Instant::now(),
2949                };
2950                self.state.fault_registry.inject(config);
2951                let result = serde_json::json!({
2952                    "injected": true,
2953                    "command": command,
2954                    "fault_type": fault_type,
2955                    "max_triggers": params.max_triggers.unwrap_or(0),
2956                });
2957                json_result(&result)
2958            }
2959            FaultAction::List => {
2960                let faults = self.state.fault_registry.list();
2961                let result = serde_json::json!({
2962                    "count": faults.len(),
2963                    "faults": faults.iter().map(|f| serde_json::json!({
2964                        "command": f.command,
2965                        "fault_type": f.fault_type,
2966                        "trigger_count": f.trigger_count,
2967                        "max_triggers": f.max_triggers,
2968                    })).collect::<Vec<_>>(),
2969                });
2970                json_result(&result)
2971            }
2972            FaultAction::Clear => {
2973                let Some(command) = params.command else {
2974                    return missing_param("command", "clear");
2975                };
2976                let removed = self.state.fault_registry.clear(&command);
2977                json_result(&serde_json::json!({
2978                    "removed": removed,
2979                    "command": command,
2980                }))
2981            }
2982            FaultAction::ClearAll => {
2983                let removed = self.state.fault_registry.clear_all();
2984                json_result(&serde_json::json!({
2985                    "removed": removed,
2986                }))
2987            }
2988        }
2989    }
2990
2991    // ── Cross-Layer Explanation ────────────────────────────────────────────
2992
2993    #[tool(
2994        description = "Correlate recent activity across all layers into a coherent narrative. \
2995            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
2996            + window events across the Rust backend and webview simultaneously.\n\n\
2997            Actions:\n\
2998            - `summary`: High-level activity summary for the last N seconds (default 30). \
2999              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3000            - `last_action`: Correlate the most recent burst of events into a causal timeline \
3001              (e.g. 'IPC call → DOM update → console.log').\n\
3002            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3003        annotations(
3004            read_only_hint = true,
3005            destructive_hint = false,
3006            idempotent_hint = true,
3007            open_world_hint = false
3008        )
3009    )]
3010    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3011        self.track_tool_call();
3012        if !self.state.privacy.is_tool_enabled("explain") {
3013            return tool_disabled("explain");
3014        }
3015
3016        match params.action {
3017            ExplainAction::Summary => {
3018                let secs = params.seconds.unwrap_or(30);
3019                let since = chrono::Utc::now()
3020                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3021                let events = self.state.event_log.since(since);
3022
3023                let mut ipc_count = 0u64;
3024                let mut dom_mutations = 0u64;
3025                let mut state_changes = 0u64;
3026                let mut console_count = 0u64;
3027                let mut window_events = 0u64;
3028                let mut interactions = 0u64;
3029                let mut top_commands: HashMap<String, u64> = HashMap::new();
3030                let mut errors: Vec<String> = Vec::new();
3031
3032                for event in &events {
3033                    match event {
3034                        victauri_core::AppEvent::Ipc(call) => {
3035                            ipc_count += 1;
3036                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3037                            if let victauri_core::IpcResult::Err(e) = &call.result {
3038                                errors.push(format!("IPC {}: {e}", call.command));
3039                            }
3040                        }
3041                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3042                            dom_mutations += u64::from(*mutation_count)
3043                        }
3044                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3045                        victauri_core::AppEvent::Console { level, message, .. } => {
3046                            console_count += 1;
3047                            if level == "error" {
3048                                errors.push(format!("console.error: {message}"));
3049                            }
3050                        }
3051                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3052                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3053                        _ => {}
3054                    }
3055                }
3056
3057                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3058                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3059                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3060
3061                let narrative = format!(
3062                    "{ipc_count} IPC call{} in the last {secs}s{}. \
3063                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3064                     {console_count} console message{}, {window_events} window event{}. {}.",
3065                    if ipc_count == 1 { "" } else { "s" },
3066                    if top.is_empty() {
3067                        String::new()
3068                    } else {
3069                        format!(
3070                            ", dominated by {}",
3071                            top.iter()
3072                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3073                                .collect::<Vec<_>>()
3074                                .join(", ")
3075                        )
3076                    },
3077                    if dom_mutations == 1 { "" } else { "s" },
3078                    if interactions == 1 { "" } else { "s" },
3079                    if console_count == 1 { "" } else { "s" },
3080                    if window_events == 1 { "" } else { "s" },
3081                    if errors.is_empty() {
3082                        "No errors".to_string()
3083                    } else {
3084                        format!(
3085                            "{} error{}",
3086                            errors.len(),
3087                            if errors.len() == 1 { "" } else { "s" }
3088                        )
3089                    },
3090                );
3091
3092                let result = serde_json::json!({
3093                    "time_window_secs": secs,
3094                    "total_events": events.len(),
3095                    "ipc_calls": ipc_count,
3096                    "dom_mutations": dom_mutations,
3097                    "state_changes": state_changes,
3098                    "console_messages": console_count,
3099                    "window_events": window_events,
3100                    "interactions": interactions,
3101                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3102                        serde_json::json!({"command": cmd, "count": n})
3103                    }).collect::<Vec<_>>(),
3104                    "errors": errors,
3105                    "narrative": narrative,
3106                });
3107                json_result(&result)
3108            }
3109            ExplainAction::LastAction => {
3110                let secs = params.seconds.unwrap_or(5);
3111                let since = chrono::Utc::now()
3112                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3113                let events = self.state.event_log.since(since);
3114
3115                let timeline: Vec<serde_json::Value> = events
3116                    .iter()
3117                    .filter(|e| !e.is_internal())
3118                    .map(|event| match event {
3119                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3120                            "time": call.timestamp.to_rfc3339_opts(
3121                                chrono::SecondsFormat::Millis, true
3122                            ),
3123                            "type": "ipc",
3124                            "detail": format!(
3125                                "{} {} ({}ms)",
3126                                call.command,
3127                                call.result,
3128                                call.duration_ms.unwrap_or(0)
3129                            ),
3130                        }),
3131                        victauri_core::AppEvent::DomMutation {
3132                            timestamp,
3133                            mutation_count,
3134                            webview_label,
3135                        } => serde_json::json!({
3136                            "time": timestamp.to_rfc3339_opts(
3137                                chrono::SecondsFormat::Millis, true
3138                            ),
3139                            "type": "dom_mutation",
3140                            "detail": format!(
3141                                "{mutation_count} element{} updated in {webview_label}",
3142                                if *mutation_count == 1 { "" } else { "s" }
3143                            ),
3144                        }),
3145                        victauri_core::AppEvent::DomInteraction {
3146                            timestamp,
3147                            action,
3148                            selector,
3149                            ..
3150                        } => serde_json::json!({
3151                            "time": timestamp.to_rfc3339_opts(
3152                                chrono::SecondsFormat::Millis, true
3153                            ),
3154                            "type": "interaction",
3155                            "detail": format!("{action} on {selector}"),
3156                        }),
3157                        victauri_core::AppEvent::StateChange {
3158                            timestamp,
3159                            key,
3160                            caused_by,
3161                        } => serde_json::json!({
3162                            "time": timestamp.to_rfc3339_opts(
3163                                chrono::SecondsFormat::Millis, true
3164                            ),
3165                            "type": "state_change",
3166                            "detail": format!(
3167                                "{key} changed{}",
3168                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3169                            ),
3170                        }),
3171                        victauri_core::AppEvent::Console {
3172                            timestamp,
3173                            level,
3174                            message,
3175                        } => serde_json::json!({
3176                            "time": timestamp.to_rfc3339_opts(
3177                                chrono::SecondsFormat::Millis, true
3178                            ),
3179                            "type": "console",
3180                            "detail": format!("console.{level}: {message}"),
3181                        }),
3182                        victauri_core::AppEvent::WindowEvent {
3183                            timestamp,
3184                            label,
3185                            event,
3186                        } => serde_json::json!({
3187                            "time": timestamp.to_rfc3339_opts(
3188                                chrono::SecondsFormat::Millis, true
3189                            ),
3190                            "type": "window_event",
3191                            "detail": format!("{event} on window '{label}'"),
3192                        }),
3193                        _ => serde_json::json!({
3194                            "time": event.timestamp().to_rfc3339_opts(
3195                                chrono::SecondsFormat::Millis, true
3196                            ),
3197                            "type": "other",
3198                            "detail": "unknown event type",
3199                        }),
3200                    })
3201                    .collect();
3202
3203                let narrative = if timeline.is_empty() {
3204                    format!("No activity in the last {secs}s.")
3205                } else {
3206                    let parts: Vec<String> = timeline
3207                        .iter()
3208                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3209                        .map(String::from)
3210                        .collect();
3211                    parts.join(" → ")
3212                };
3213
3214                let result = serde_json::json!({
3215                    "time_window_secs": secs,
3216                    "event_count": timeline.len(),
3217                    "timeline": timeline,
3218                    "narrative": narrative,
3219                });
3220                json_result(&result)
3221            }
3222            ExplainAction::Diff => {
3223                let secs = params.seconds.unwrap_or(10);
3224                let since = chrono::Utc::now()
3225                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3226                let events = self.state.event_log.since(since);
3227
3228                let mut ipc_commands: Vec<String> = Vec::new();
3229                let mut dom_changes = 0u64;
3230                let mut error_count = 0u64;
3231                let mut interaction_count = 0u64;
3232                let mut console_messages = 0u64;
3233
3234                for event in &events {
3235                    if event.is_internal() {
3236                        continue;
3237                    }
3238                    match event {
3239                        victauri_core::AppEvent::Ipc(call) => {
3240                            ipc_commands.push(call.command.clone());
3241                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3242                                error_count += 1;
3243                            }
3244                        }
3245                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3246                            dom_changes += u64::from(*mutation_count)
3247                        }
3248                        victauri_core::AppEvent::DomInteraction { .. } => {
3249                            interaction_count += 1;
3250                        }
3251                        victauri_core::AppEvent::Console { level, .. } => {
3252                            console_messages += 1;
3253                            if level == "error" {
3254                                error_count += 1;
3255                            }
3256                        }
3257                        _ => {}
3258                    }
3259                }
3260
3261                ipc_commands.dedup();
3262
3263                let result = serde_json::json!({
3264                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3265                    "time_window_secs": secs,
3266                    "total_events": events.len(),
3267                    "ipc_calls_made": ipc_commands.len(),
3268                    "unique_commands": ipc_commands,
3269                    "dom_elements_changed": dom_changes,
3270                    "interactions": interaction_count,
3271                    "console_messages": console_messages,
3272                    "errors": error_count,
3273                });
3274                json_result(&result)
3275            }
3276        }
3277    }
3278}
3279
3280impl VictauriMcpHandler {
3281    /// Create a new handler backed by the given state and webview bridge.
3282    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3283        Self {
3284            state,
3285            bridge,
3286            subscriptions: Arc::new(Mutex::new(HashSet::new())),
3287            bridge_checked: Arc::new(AtomicBool::new(false)),
3288            probed_labels: Arc::new(Mutex::new(HashSet::new())),
3289            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3290        }
3291    }
3292
3293    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3294        self.state.privacy.is_tool_enabled(name)
3295    }
3296
3297    pub(crate) async fn execute_tool(
3298        &self,
3299        name: &str,
3300        args: serde_json::Value,
3301    ) -> Result<CallToolResult, rest::ToolCallError> {
3302        if !self.state.privacy.is_tool_enabled(name) {
3303            return Ok(tool_disabled(name));
3304        }
3305        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3306        let start = std::time::Instant::now();
3307        tracing::debug!(tool = %name, "REST tool invocation started");
3308
3309        let result = match name {
3310            "eval_js" => {
3311                let p: EvalJsParams = Self::parse_args(args)?;
3312                self.eval_js(Parameters(p)).await
3313            }
3314            "dom_snapshot" => {
3315                let p: SnapshotParams = Self::parse_args(args)?;
3316                self.dom_snapshot(Parameters(p)).await
3317            }
3318            "find_elements" => {
3319                let p: FindElementsParams = Self::parse_args(args)?;
3320                self.find_elements(Parameters(p)).await
3321            }
3322            "invoke_command" => {
3323                let p: InvokeCommandParams = Self::parse_args(args)?;
3324                self.invoke_command(Parameters(p)).await
3325            }
3326            "screenshot" => {
3327                let p: ScreenshotParams = Self::parse_args(args)?;
3328                self.screenshot(Parameters(p)).await
3329            }
3330            "verify_state" => {
3331                let p: VerifyStateParams = Self::parse_args(args)?;
3332                self.verify_state(Parameters(p)).await
3333            }
3334            "detect_ghost_commands" => {
3335                let p: GhostCommandParams = Self::parse_args(args)?;
3336                self.detect_ghost_commands(Parameters(p)).await
3337            }
3338            "check_ipc_integrity" => {
3339                let p: IpcIntegrityParams = Self::parse_args(args)?;
3340                self.check_ipc_integrity(Parameters(p)).await
3341            }
3342            "wait_for" => {
3343                let p: WaitForParams = Self::parse_args(args)?;
3344                self.wait_for(Parameters(p)).await
3345            }
3346            "assert_semantic" => {
3347                let p: SemanticAssertParams = Self::parse_args(args)?;
3348                self.assert_semantic(Parameters(p)).await
3349            }
3350            "resolve_command" => {
3351                let p: ResolveCommandParams = Self::parse_args(args)?;
3352                self.resolve_command(Parameters(p)).await
3353            }
3354            "get_registry" => {
3355                let p: RegistryParams = Self::parse_args(args)?;
3356                self.get_registry(Parameters(p)).await
3357            }
3358            "get_memory_stats" => self.get_memory_stats().await,
3359            "get_plugin_info" => self.get_plugin_info().await,
3360            "get_diagnostics" => {
3361                let p: DiagnosticsParams = Self::parse_args(args)?;
3362                self.get_diagnostics(Parameters(p)).await
3363            }
3364            "app_info" => self.app_info().await,
3365            "list_app_dir" => {
3366                let p: ListAppDirParams = Self::parse_args(args)?;
3367                self.list_app_dir(Parameters(p)).await
3368            }
3369            "read_app_file" => {
3370                let p: ReadAppFileParams = Self::parse_args(args)?;
3371                self.read_app_file(Parameters(p)).await
3372            }
3373            #[cfg(feature = "sqlite")]
3374            "query_db" => {
3375                let p: QueryDbParams = Self::parse_args(args)?;
3376                self.query_db(Parameters(p)).await
3377            }
3378            "interact" => {
3379                let p: InteractParams = Self::parse_args(args)?;
3380                self.interact(Parameters(p)).await
3381            }
3382            "input" => {
3383                let p: InputParams = Self::parse_args(args)?;
3384                self.input(Parameters(p)).await
3385            }
3386            "window" => {
3387                let p: WindowParams = Self::parse_args(args)?;
3388                self.window(Parameters(p)).await
3389            }
3390            "storage" => {
3391                let p: StorageParams = Self::parse_args(args)?;
3392                self.storage(Parameters(p)).await
3393            }
3394            "navigate" => {
3395                let p: NavigateParams = Self::parse_args(args)?;
3396                self.navigate(Parameters(p)).await
3397            }
3398            "recording" => {
3399                let p: RecordingParams = Self::parse_args(args)?;
3400                self.recording(Parameters(p)).await
3401            }
3402            "inspect" => {
3403                let p: InspectParams = Self::parse_args(args)?;
3404                self.inspect(Parameters(p)).await
3405            }
3406            "css" => {
3407                let p: CssParams = Self::parse_args(args)?;
3408                self.css(Parameters(p)).await
3409            }
3410            "route" => {
3411                let p: RouteParams = Self::parse_args(args)?;
3412                self.route(Parameters(p)).await
3413            }
3414            "trace" => {
3415                let p: TraceParams = Self::parse_args(args)?;
3416                self.trace(Parameters(p)).await
3417            }
3418            "animation" => {
3419                let p: AnimationParams = Self::parse_args(args)?;
3420                self.animation(Parameters(p)).await
3421            }
3422            "logs" => {
3423                let p: LogsParams = Self::parse_args(args)?;
3424                self.logs(Parameters(p)).await
3425            }
3426            "introspect" => {
3427                let p: IntrospectParams = Self::parse_args(args)?;
3428                self.introspect(Parameters(p)).await
3429            }
3430            "fault" => {
3431                let p: FaultParams = Self::parse_args(args)?;
3432                self.fault(Parameters(p)).await
3433            }
3434            "explain" => {
3435                let p: ExplainParams = Self::parse_args(args)?;
3436                self.explain(Parameters(p)).await
3437            }
3438            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3439        };
3440
3441        let elapsed = start.elapsed();
3442        tracing::debug!(
3443            tool = %name,
3444            elapsed_ms = elapsed.as_millis() as u64,
3445            "REST tool invocation completed"
3446        );
3447
3448        if self.state.privacy.redaction_enabled {
3449            Ok(Self::redact_result(result, &self.state.privacy))
3450        } else {
3451            Ok(result)
3452        }
3453    }
3454
3455    fn parse_args<T: serde::de::DeserializeOwned>(
3456        args: serde_json::Value,
3457    ) -> Result<T, rest::ToolCallError> {
3458        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3459    }
3460
3461    fn redact_result(
3462        mut result: CallToolResult,
3463        privacy: &crate::privacy::PrivacyConfig,
3464    ) -> CallToolResult {
3465        for item in &mut result.content {
3466            if let RawContent::Text(ref mut tc) = item.raw {
3467                tc.text = privacy.redact_output(&tc.text);
3468            }
3469        }
3470        result
3471    }
3472
3473    fn track_tool_call(&self) {
3474        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3475    }
3476
3477    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3478        match dir.unwrap_or(AppDir::Data) {
3479            AppDir::Data => self.bridge.app_data_dir(),
3480            AppDir::Config => self.bridge.app_config_dir(),
3481            AppDir::Log => self.bridge.app_log_dir(),
3482            AppDir::LocalData => self.bridge.app_local_data_dir(),
3483        }
3484    }
3485
3486    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3487        let canon_base = std::fs::canonicalize(base)
3488            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3489        let canon_target = std::fs::canonicalize(target)
3490            .map_err(|e| format!("cannot resolve target path: {e}"))?;
3491        if !canon_target.starts_with(&canon_base) {
3492            return Err("path traversal not allowed".to_string());
3493        }
3494        Ok(())
3495    }
3496
3497    fn list_dir_recursive(
3498        dir: &std::path::Path,
3499        base: &std::path::Path,
3500        depth: u32,
3501        max_depth: u32,
3502        pattern: Option<&str>,
3503        entries: &mut Vec<serde_json::Value>,
3504    ) {
3505        let Ok(read_dir) = std::fs::read_dir(dir) else {
3506            return;
3507        };
3508        for entry in read_dir.flatten() {
3509            let path = entry.path();
3510            if path.is_symlink() {
3511                continue;
3512            }
3513            let name = entry.file_name().to_string_lossy().into_owned();
3514            let relative = path
3515                .strip_prefix(base)
3516                .unwrap_or(&path)
3517                .to_string_lossy()
3518                .into_owned();
3519
3520            if let Some(pat) = pattern
3521                && !Self::matches_glob(&name, pat)
3522                && !path.is_dir()
3523            {
3524                continue;
3525            }
3526
3527            let is_dir = path.is_dir();
3528            let meta = std::fs::metadata(&path).ok();
3529
3530            entries.push(serde_json::json!({
3531                "name": name,
3532                "path": relative,
3533                "is_dir": is_dir,
3534                "size": meta.as_ref().map(std::fs::Metadata::len),
3535                "modified": meta.as_ref()
3536                    .and_then(|m| m.modified().ok())
3537                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3538                        .unwrap_or_default().as_secs()),
3539            }));
3540
3541            if is_dir && depth < max_depth {
3542                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3543            }
3544        }
3545    }
3546
3547    fn matches_glob(name: &str, pattern: &str) -> bool {
3548        if pattern == "*" {
3549            return true;
3550        }
3551        if let Some(suffix) = pattern.strip_prefix("*.") {
3552            return name.ends_with(&format!(".{suffix}"));
3553        }
3554        if let Some(prefix) = pattern.strip_suffix("*") {
3555            return name.starts_with(prefix);
3556        }
3557        name == pattern
3558    }
3559
3560    /// Probe every window's JS bridge and report which are introspectable. A
3561    /// visible window that fails to respond almost always lacks the
3562    /// `victauri:default` capability — Tauri's permission ACL silently blocks
3563    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
3564    /// turns that silent dead-end into an actionable, up-front diagnosis.
3565    async fn window_introspectability(&self) -> CallToolResult {
3566        let labels = self.bridge.list_window_labels();
3567        let states = self.bridge.get_window_states(None);
3568        let mut report = Vec::with_capacity(labels.len());
3569        let mut blind = 0usize;
3570        for label in &labels {
3571            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3572            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3573            if !introspectable {
3574                blind += 1;
3575            }
3576            let note = if introspectable {
3577                "ok — Victauri JS bridge is responding".to_string()
3578            } else if visible == Some(true) {
3579                format!(
3580                    "NOT introspectable although the window is visible — almost certainly missing \
3581                     the Victauri capability. Add \"victauri:default\" to the capability file \
3582                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3583                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3584                )
3585            } else {
3586                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3587                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3588                    .to_string()
3589            };
3590            report.push(serde_json::json!({
3591                "label": label,
3592                "visible": visible,
3593                "introspectable": introspectable,
3594                "note": note,
3595            }));
3596        }
3597        let hint = if blind > 0 {
3598            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3599             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3600             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3601             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3602             not just for the main window. (Note: probing a blind window takes ~2s each.)"
3603        } else {
3604            "All windows are introspectable."
3605        };
3606        json_result(&serde_json::json!({
3607            "windows": report,
3608            "introspectable_count": labels.len().saturating_sub(blind),
3609            "blind_count": blind,
3610            "hint": hint,
3611        }))
3612    }
3613
3614    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3615        match self.eval_with_return(code, webview_label).await {
3616            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3617            Err(e) => tool_error(e),
3618        }
3619    }
3620
3621    async fn eval_with_return(
3622        &self,
3623        code: &str,
3624        webview_label: Option<&str>,
3625    ) -> Result<String, String> {
3626        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3627            .await
3628    }
3629
3630    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3631        let id = uuid::Uuid::new_v4().to_string();
3632        let (tx, rx) = tokio::sync::oneshot::channel();
3633        {
3634            let mut pending = self.state.pending_evals.lock().await;
3635            pending.insert(id.clone(), tx);
3636        }
3637        let id_js = js_string(&id);
3638        let probe = format!(
3639            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3640        );
3641        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3642            self.state.pending_evals.lock().await.remove(&id);
3643            return Err(format!("eval injection failed: {e}"));
3644        }
3645        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3646            Ok(())
3647        } else {
3648            self.state.pending_evals.lock().await.remove(&id);
3649            let label = webview_label.unwrap_or("default");
3650            Err(format!(
3651                "bridge not responding on window '{label}' — the window may be hidden, \
3652                 missing the victauri capability, or the JS bridge is not loaded"
3653            ))
3654        }
3655    }
3656
3657    async fn eval_with_return_timeout(
3658        &self,
3659        code: &str,
3660        webview_label: Option<&str>,
3661        timeout: std::time::Duration,
3662    ) -> Result<String, String> {
3663        self.track_tool_call();
3664
3665        // Wait for the JS bridge ready signal (sent on bridge init) before
3666        // attempting evals.  For explicitly targeted windows the probe
3667        // mechanism is still used because the ready signal only proves that
3668        // *some* webview's bridge loaded — not necessarily the targeted one.
3669        if !self
3670            .state
3671            .bridge_ready
3672            .load(std::sync::atomic::Ordering::Acquire)
3673        {
3674            let notified = self.state.bridge_notify.notified();
3675            if !self
3676                .state
3677                .bridge_ready
3678                .load(std::sync::atomic::Ordering::Acquire)
3679            {
3680                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3681            }
3682        }
3683
3684        // Reserved sentinel key for the default (unlabeled) window — cannot
3685        // collide with a real label.
3686        let label_key =
3687            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3688
3689        // Proactively probe explicitly-targeted windows once (cached), so a
3690        // hidden/unready window fails fast rather than after the full timeout.
3691        if webview_label.is_some() {
3692            let already_probed = self.probed_labels.lock().await.contains(&label_key);
3693            if !already_probed {
3694                self.probe_bridge(webview_label).await?;
3695                self.probed_labels.lock().await.insert(label_key.clone());
3696            }
3697        }
3698
3699        // Resilience: if the PREVIOUS eval on this window timed out, the bridge
3700        // may have gone away (the webview reloaded or the app crashed). Do a
3701        // fast liveness probe so this call fails in ~2s with a clear error
3702        // instead of blocking the full timeout again. If the bridge is alive
3703        // (the earlier timeout was slow code / an infinite loop), the probe
3704        // succeeds quickly and we proceed normally.
3705        if self.timed_out_labels.lock().await.remove(&label_key) {
3706            self.probe_bridge(webview_label).await.map_err(|e| {
3707                format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
3708            })?;
3709        }
3710
3711        let id = uuid::Uuid::new_v4().to_string();
3712        let (tx, rx) = tokio::sync::oneshot::channel();
3713
3714        {
3715            let mut pending = self.state.pending_evals.lock().await;
3716            if pending.len() >= MAX_PENDING_EVALS {
3717                return Err(format!(
3718                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
3719                ));
3720            }
3721            pending.insert(id.clone(), tx);
3722        }
3723
3724        // Auto-prepend `return` so bare expressions produce a value — but ONLY
3725        // for single expressions. Multi-statement blocks (or code containing an
3726        // explicit `return`) are used as-is. Prepending `return` to a statement
3727        // block like `foo(); return bar()` would parse as `return foo();` and
3728        // silently discard everything after the first statement (issue: core
3729        // primitive returned wrong/undefined values for "do X, then return Y").
3730        let code = if should_prepend_return(code) {
3731            format!("return {}", code.trim())
3732        } else {
3733            code.trim().to_string()
3734        };
3735
3736        let id_js = js_string(&id);
3737
3738        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
3739        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
3740        // the script below; if it has a parse error the WHOLE script fails to parse and the
3741        // try/catch never runs, so the callback never fires. We cannot wrap the code in
3742        // `new Function`/`AsyncFunction` to surface the SyntaxError, because dynamic code
3743        // generation is gated by the same `unsafe-eval` CSP that blocks `eval()` — which is
3744        // exactly why the bridge uses an inline async-IIFE in the first place. Instead an
3745        // independent watchdog (which always parses) reports a parse error quickly: the
3746        // user-code script sets a `started` flag at its very top, so a script that fails to
3747        // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started`
3748        // immediately and is left to run to the real timeout — the watchdog only fires when
3749        // the code never began executing.
3750        let watchdog = format!(
3751            r"
3752            (function () {{
3753                window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
3754                var s = (window.__VIC_EVAL__[{id_js}] =
3755                    window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
3756                setTimeout(function () {{
3757                    if (s.started || s.done) return;
3758                    s.done = true;
3759                    try {{
3760                        window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3761                            id: {id_js},
3762                            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)' }})
3763                        }});
3764                    }} catch (e) {{}}
3765                    delete window.__VIC_EVAL__[{id_js}];
3766                }}, {PARSE_WATCHDOG_MS});
3767            }})();
3768            "
3769        );
3770
3771        let inject = format!(
3772            r"
3773            (async () => {{
3774                var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
3775                if (__s) __s.started = true;
3776                try {{
3777                    const __result = await (async () => {{ {code} }})();
3778                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
3779                    const __type = __result === undefined ? 'undefined'
3780                        : __result === null ? 'null' : 'value';
3781                    const __val = __type === 'undefined' ? null
3782                        : __type === 'null' ? null : __result;
3783                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3784                        id: {id_js},
3785                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
3786                    }});
3787                }} catch (e) {{
3788                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
3789                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
3790                        id: {id_js},
3791                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
3792                    }});
3793                }}
3794            }})();
3795            "
3796        );
3797
3798        // Inject the watchdog first so it is armed before the user code runs. Order is not
3799        // critical (the user-code script no-ops the watchdog state if it ran first), but
3800        // arming first minimises the window.
3801        if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
3802            self.state.pending_evals.lock().await.remove(&id);
3803            return Err(format!("eval injection failed: {e}"));
3804        }
3805        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
3806            self.state.pending_evals.lock().await.remove(&id);
3807            return Err(format!("eval injection failed: {e}"));
3808        }
3809
3810        match tokio::time::timeout(timeout, rx).await {
3811            Ok(Ok(raw)) => {
3812                self.check_bridge_version_once();
3813                if raw.len() > MAX_EVAL_RESULT_LEN {
3814                    return Err(format!(
3815                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
3816                        raw.len()
3817                    ));
3818                }
3819                unwrap_eval_envelope(raw)
3820            }
3821            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
3822            Err(_) => {
3823                self.state.pending_evals.lock().await.remove(&id);
3824                // Mark this window so the NEXT eval does a fast liveness probe —
3825                // if the bridge is gone (reloaded/crashed) the next call fails in
3826                // ~2s instead of blocking the full timeout again.
3827                self.timed_out_labels.lock().await.insert(label_key.clone());
3828                Err(format!(
3829                    "eval timed out after {}s — the code began executing but never resolved. \
3830                     (A syntax/parse error would have failed fast via the parse watchdog, so \
3831                     this is NOT a parse error.) Common causes: an unresolved promise, an \
3832                     infinite loop, an `await` on something that never settles, or the webview \
3833                     reloaded / the app stopped responding mid-eval. If the app may have \
3834                     navigated or crashed, retry (the next call fails fast if the bridge is \
3835                     gone).",
3836                    timeout.as_secs()
3837                ))
3838            }
3839        }
3840    }
3841
3842    #[cfg(feature = "sqlite")]
3843    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
3844        // Roots: configured db_search_paths first, then app directories.
3845        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
3846        for d in [
3847            self.bridge.app_data_dir(),
3848            self.bridge.app_local_data_dir(),
3849            self.bridge.app_config_dir(),
3850        ]
3851        .into_iter()
3852        .flatten()
3853        {
3854            roots.push(d);
3855        }
3856
3857        let path = if let Some(p) = db_path {
3858            let candidate = std::path::Path::new(p);
3859            if candidate.is_absolute() {
3860                if !roots
3861                    .iter()
3862                    .any(|r| Self::safe_within(r, candidate).is_ok())
3863                {
3864                    return Err(format!(
3865                        "absolute path '{p}' is not within an allowed directory; \
3866                         register its parent via VictauriBuilder::db_search_paths"
3867                    ));
3868                }
3869                candidate.to_path_buf()
3870            } else {
3871                roots
3872                    .iter()
3873                    .map(|r| r.join(p))
3874                    .find(|c| c.exists())
3875                    .ok_or_else(|| format!("database not found: {p}"))?
3876            }
3877        } else {
3878            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
3879            // OS app dirs that hold WebView internals); WebView/engine internal stores are
3880            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
3881            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
3882                roots.clone()
3883            } else {
3884                self.state.db_search_paths.clone()
3885            };
3886            crate::database::select_app_database(&select_dirs)?
3887        };
3888        // No further containment check needed: the path is either discovered
3889        // within an allowed root, an existing relative file joined onto an
3890        // allowed root, or an absolute path already verified above. (A
3891        // safe_within check against app_data_dir would fail when that directory
3892        // does not exist — common for apps that store data elsewhere.)
3893        let path_str = path
3894            .to_str()
3895            .ok_or_else(|| "invalid path encoding".to_string())?
3896            .to_string();
3897
3898        tokio::task::spawn_blocking(move || {
3899            let conn = rusqlite::Connection::open_with_flags(
3900                &path_str,
3901                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3902            )
3903            .map_err(|e| format!("cannot open database: {e}"))?;
3904
3905            let journal_mode: String = conn
3906                .pragma_query_value(None, "journal_mode", |r| r.get(0))
3907                .unwrap_or_else(|_| "unknown".to_string());
3908
3909            let page_count: i64 = conn
3910                .pragma_query_value(None, "page_count", |r| r.get(0))
3911                .unwrap_or(0);
3912
3913            let page_size: i64 = conn
3914                .pragma_query_value(None, "page_size", |r| r.get(0))
3915                .unwrap_or(0);
3916
3917            let freelist_count: i64 = conn
3918                .pragma_query_value(None, "freelist_count", |r| r.get(0))
3919                .unwrap_or(0);
3920
3921            let wal_checkpoint: String = if journal_mode == "wal" {
3922                let mut info = String::from("n/a");
3923                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
3924                    let busy: i64 = r.get(0)?;
3925                    let checkpointed: i64 = r.get(1)?;
3926                    let total: i64 = r.get(2)?;
3927                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
3928                    Ok(())
3929                });
3930                info
3931            } else {
3932                "n/a (not WAL mode)".to_string()
3933            };
3934
3935            let integrity: String = conn
3936                .pragma_query_value(None, "quick_check", |r| r.get(0))
3937                .unwrap_or_else(|_| "failed".to_string());
3938
3939            let db_size_bytes = page_count * page_size;
3940            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
3941
3942            let mut tables = Vec::new();
3943            if let Ok(mut stmt) =
3944                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
3945                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
3946            {
3947                for name in rows.flatten() {
3948                    let count: i64 = conn
3949                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
3950                        .unwrap_or(0);
3951                    tables.push(serde_json::json!({
3952                        "name": name,
3953                        "row_count": count,
3954                    }));
3955                }
3956            }
3957
3958            Ok(serde_json::json!({
3959                "database": path_str,
3960                "journal_mode": journal_mode,
3961                "page_count": page_count,
3962                "page_size": page_size,
3963                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
3964                "freelist_count": freelist_count,
3965                "wal_checkpoint": wal_checkpoint,
3966                "integrity_check": integrity,
3967                "tables": tables,
3968            }))
3969        })
3970        .await
3971        .map_err(|e| format!("db health task failed: {e}"))?
3972    }
3973
3974    fn check_bridge_version_once(&self) {
3975        if self.bridge_checked.swap(true, Ordering::Relaxed) {
3976            return;
3977        }
3978        let handler = self.clone();
3979        tokio::spawn(async move {
3980            match handler
3981                .eval_with_return_timeout(
3982                    "window.__VICTAURI__?.version",
3983                    None,
3984                    std::time::Duration::from_secs(5),
3985                )
3986                .await
3987            {
3988                Ok(v) => {
3989                    let v = v.trim_matches('"');
3990                    if v == BRIDGE_VERSION {
3991                        tracing::debug!("Bridge version verified: {v}");
3992                    } else {
3993                        tracing::warn!(
3994                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
3995                        );
3996                    }
3997                }
3998                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
3999            }
4000        });
4001    }
4002}
4003
4004const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4005It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4006(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4007(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4008\n\nBACKEND tools (direct Rust access, no webview needed): \
4009'app_info' (app config, directory paths, discovered databases, process info), \
4010'list_app_dir' (browse app data/config/log directories), \
4011'read_app_file' (read files from app directories), \
4012'query_db' (read-only SQLite queries with auto-discovery). \
4013\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4014'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4015capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4016Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4017capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4018task tracking, and automatic Tauri event bus monitoring. \
4019'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4020drops, and response corruption into Tauri commands at the Rust layer. \
4021'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4022activity across IPC + DOM + console + network + window events into a coherent narrative. \
4023\n\nWEBVIEW tools: \
4024'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4025'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4026'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4027\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4028\n\nCOMPOUND tools with an 'action' parameter: \
4029'window' (get_state, list, manage, resize, move_to, set_title), \
4030'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4031set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4032get_events, events_between, get_replay, export, import, replay), \
4033'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4034\n\nOTHER: verify_state, wait_for, assert_semantic, resolve_command, \
4035get_memory_stats, get_plugin_info, get_diagnostics.";
4036
4037impl ServerHandler for VictauriMcpHandler {
4038    fn get_info(&self) -> ServerInfo {
4039        ServerInfo::new(
4040            ServerCapabilities::builder()
4041                .enable_tools()
4042                .enable_resources()
4043                .enable_resources_subscribe()
4044                .build(),
4045        )
4046        .with_instructions(SERVER_INSTRUCTIONS)
4047    }
4048
4049    async fn list_tools(
4050        &self,
4051        _request: Option<PaginatedRequestParams>,
4052        _context: RequestContext<RoleServer>,
4053    ) -> Result<ListToolsResult, ErrorData> {
4054        let all_tools = Self::tool_router().list_all();
4055        let filtered: Vec<Tool> = all_tools
4056            .into_iter()
4057            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4058            .collect();
4059        Ok(ListToolsResult {
4060            tools: filtered,
4061            ..Default::default()
4062        })
4063    }
4064
4065    async fn call_tool(
4066        &self,
4067        request: CallToolRequestParams,
4068        context: RequestContext<RoleServer>,
4069    ) -> Result<CallToolResult, ErrorData> {
4070        let tool_name: String = request.name.as_ref().to_owned();
4071        if !self.state.privacy.is_tool_enabled(&tool_name) {
4072            tracing::debug!(tool = %tool_name, "tool call blocked by privacy config");
4073            return Ok(tool_disabled(&tool_name));
4074        }
4075        self.state
4076            .tool_invocations
4077            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4078        let start = std::time::Instant::now();
4079        tracing::debug!(tool = %tool_name, "tool invocation started");
4080        let ctx = ToolCallContext::new(self, request, context);
4081        let result = Self::tool_router().call(ctx).await;
4082        let elapsed = start.elapsed();
4083        tracing::debug!(
4084            tool = %tool_name,
4085            elapsed_ms = elapsed.as_millis() as u64,
4086            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4087            "tool invocation completed"
4088        );
4089
4090        // Centralized output redaction: apply to all text content so no
4091        // individual tool can accidentally leak secrets.
4092        if self.state.privacy.redaction_enabled {
4093            result.map(|mut r| {
4094                for item in &mut r.content {
4095                    if let RawContent::Text(ref mut tc) = item.raw {
4096                        tc.text = self.state.privacy.redact_output(&tc.text);
4097                    }
4098                }
4099                r
4100            })
4101        } else {
4102            result
4103        }
4104    }
4105
4106    fn get_tool(&self, name: &str) -> Option<Tool> {
4107        if !self.state.privacy.is_tool_enabled(name) {
4108            return None;
4109        }
4110        Self::tool_router().get(name).cloned()
4111    }
4112
4113    async fn list_resources(
4114        &self,
4115        _request: Option<PaginatedRequestParams>,
4116        _context: RequestContext<RoleServer>,
4117    ) -> Result<ListResourcesResult, ErrorData> {
4118        Ok(ListResourcesResult {
4119            resources: vec![
4120                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4121                    .with_description(
4122                        "Live IPC call log — all commands invoked between frontend and backend",
4123                    )
4124                    .with_mime_type("application/json")
4125                    .no_annotation(),
4126                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4127                    .with_description(
4128                        "Current state of all Tauri windows — position, size, visibility, focus",
4129                    )
4130                    .with_mime_type("application/json")
4131                    .no_annotation(),
4132                RawResource::new(RESOURCE_URI_STATE, "state")
4133                    .with_description(
4134                        "Victauri plugin state — event count, registered commands, memory stats",
4135                    )
4136                    .with_mime_type("application/json")
4137                    .no_annotation(),
4138            ],
4139            ..Default::default()
4140        })
4141    }
4142
4143    async fn read_resource(
4144        &self,
4145        request: ReadResourceRequestParams,
4146        _context: RequestContext<RoleServer>,
4147    ) -> Result<ReadResourceResult, ErrorData> {
4148        let uri = &request.uri;
4149        let json = match uri.as_str() {
4150            RESOURCE_URI_IPC_LOG => {
4151                if let Ok(json) = self
4152                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
4153                    .await
4154                {
4155                    json
4156                } else {
4157                    let calls = self.state.event_log.ipc_calls();
4158                    serde_json::to_string_pretty(&calls)
4159                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4160                }
4161            }
4162            RESOURCE_URI_WINDOWS => {
4163                let states = self.bridge.get_window_states(None);
4164                serde_json::to_string_pretty(&states)
4165                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4166            }
4167            RESOURCE_URI_STATE => {
4168                let state_json = serde_json::json!({
4169                    "events_captured": self.state.event_log.len(),
4170                    "commands_registered": self.state.registry.count(),
4171                    "memory": crate::memory::current_stats(),
4172                    "port": self.state.port.load(Ordering::Relaxed),
4173                });
4174                serde_json::to_string_pretty(&state_json)
4175                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4176            }
4177            _ => {
4178                return Err(ErrorData::resource_not_found(
4179                    format!("unknown resource: {uri}"),
4180                    None,
4181                ));
4182            }
4183        };
4184
4185        let json = if self.state.privacy.redaction_enabled {
4186            self.state.privacy.redact_output(&json)
4187        } else {
4188            json
4189        };
4190
4191        Ok(ReadResourceResult::new(vec![ResourceContents::text(
4192            json, uri,
4193        )]))
4194    }
4195
4196    async fn subscribe(
4197        &self,
4198        request: SubscribeRequestParams,
4199        _context: RequestContext<RoleServer>,
4200    ) -> Result<(), ErrorData> {
4201        let uri = &request.uri;
4202        match uri.as_str() {
4203            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4204                self.subscriptions.lock().await.insert(uri.clone());
4205                tracing::info!("Client subscribed to resource: {uri}");
4206                Ok(())
4207            }
4208            _ => Err(ErrorData::resource_not_found(
4209                format!("unknown resource: {uri}"),
4210                None,
4211            )),
4212        }
4213    }
4214
4215    async fn unsubscribe(
4216        &self,
4217        request: UnsubscribeRequestParams,
4218        _context: RequestContext<RoleServer>,
4219    ) -> Result<(), ErrorData> {
4220        self.subscriptions.lock().await.remove(&request.uri);
4221        tracing::info!("Client unsubscribed from resource: {}", request.uri);
4222        Ok(())
4223    }
4224}
4225
4226/// Build a JS expression that takes an array of log entries (`source_expr`),
4227/// keeps at most `limit` of the most recent, and truncates any per-entry field
4228/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
4229/// the eval size cap on busy apps where individual entries carry large bodies.
4230///
4231/// The returned code is a complete `return (...)` statement.
4232fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4233    let mb = MAX_LOG_FIELD_BYTES;
4234    format!(
4235        r"return (function() {{
4236            var MB = {mb};
4237            function trimField(v) {{
4238                if (typeof v === 'string') {{
4239                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4240                }}
4241                if (v && typeof v === 'object') {{
4242                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4243                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4244                }}
4245                return v;
4246            }}
4247            function trimEntry(e) {{
4248                if (e == null || typeof e !== 'object') return e;
4249                var out = Array.isArray(e) ? [] : {{}};
4250                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4251                return out;
4252            }}
4253            var arr = {source_expr} || [];
4254            if (arr.length > {limit}) arr = arr.slice(-{limit});
4255            return arr.map(trimEntry);
4256        }})()"
4257    )
4258}
4259
4260/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
4261/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
4262/// value/error string returned to callers.
4263///
4264/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
4265/// disabled — an unbounded recursive parse of a pathologically deep result
4266/// overflows the worker thread stack and crashes the host). When the parse
4267/// fails because the value is too deeply nested, the envelope is stripped by
4268/// string slicing (no recursion) so the actual value is still returned rather
4269/// than leaking the raw envelope string.
4270fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4271    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4272        if let Some(err) = envelope.get("__victauri_err") {
4273            return Err(format!(
4274                "JavaScript error: {}",
4275                err.as_str().unwrap_or("unknown error")
4276            ));
4277        }
4278        if envelope.get("__victauri_ok").is_some() {
4279            let js_type = envelope
4280                .get("__victauri_type")
4281                .and_then(|t| t.as_str())
4282                .unwrap_or("value");
4283            return match js_type {
4284                "undefined" => Ok("undefined".to_string()),
4285                "null" => Ok("null".to_string()),
4286                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4287                    .unwrap_or_else(|_| "null".to_string())),
4288            };
4289        }
4290    }
4291    // Fallback for results too deeply nested for the recursion-limited parser.
4292    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4293        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4294    {
4295        return Ok(after[..idx].to_string());
4296    }
4297    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4298        let msg = after.trim_end_matches('}').trim_matches('"');
4299        return Err(format!("JavaScript error: {msg}"));
4300    }
4301    Ok(raw)
4302}
4303
4304/// Statement keywords where a leading `return` would be a syntax error.
4305const STMT_STARTS: &[&str] = &[
4306    "return ",
4307    "return;",
4308    "return\n",
4309    "return\t",
4310    "if ",
4311    "if(",
4312    "for ",
4313    "for(",
4314    "while ",
4315    "while(",
4316    "switch ",
4317    "switch(",
4318    "try ",
4319    "try{",
4320    "const ",
4321    "let ",
4322    "var ",
4323    "function ",
4324    "function(",
4325    "function*",
4326    "class ",
4327    "throw ",
4328    "do ",
4329    "do{",
4330    "{",
4331    "async function",
4332    "debugger",
4333];
4334
4335/// String/template/comment scan state for [`should_prepend_return`].
4336#[derive(PartialEq, Clone, Copy)]
4337enum ScanState {
4338    Code,
4339    SingleQuote,
4340    DoubleQuote,
4341    Template,
4342}
4343
4344/// Decide whether to wrap `code` with a leading `return`.
4345///
4346/// Only a single bare expression should get `return` prepended. Code that is a
4347/// multi-statement block, contains an explicit top-level `return`, or starts
4348/// with a statement keyword is used as-is — prepending `return` to such code
4349/// would execute only the first statement and silently discard the rest.
4350///
4351/// The scan is string/template/comment-aware and only treats a `;` or an
4352/// explicit `return` token as significant when it occurs at bracket depth 0
4353/// outside of any string, template literal, or comment.
4354fn should_prepend_return(code: &str) -> bool {
4355    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4356
4357    let code = code.trim();
4358    if code.is_empty() {
4359        return false;
4360    }
4361
4362    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4363        return false;
4364    }
4365
4366    let bytes = code.as_bytes();
4367    let mut i = 0;
4368    let mut depth: i32 = 0;
4369    let mut state = ScanState::Code;
4370
4371    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4372    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
4373    let is_return_token = |i: usize| -> bool {
4374        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4375        prev_ok
4376            && code[i..].starts_with("return")
4377            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4378    };
4379
4380    while i < bytes.len() {
4381        let c = bytes[i];
4382        match state {
4383            Code => match c {
4384                b'\'' => state = SingleQuote,
4385                b'"' => state = DoubleQuote,
4386                b'`' => state = Template,
4387                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4388                    while i < bytes.len() && bytes[i] != b'\n' {
4389                        i += 1;
4390                    }
4391                    continue;
4392                }
4393                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4394                    i += 2;
4395                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4396                        i += 1;
4397                    }
4398                    i += 2;
4399                    continue;
4400                }
4401                b'(' | b'[' | b'{' => depth += 1,
4402                b')' | b']' | b'}' => depth -= 1,
4403                // A top-level `;` with more code after it == multi-statement.
4404                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4405                // An explicit top-level `return` token means the code already returns.
4406                b'r' if depth <= 0 && is_return_token(i) => return false,
4407                _ => {}
4408            },
4409            SingleQuote => {
4410                if c == b'\\' {
4411                    i += 1;
4412                } else if c == b'\'' {
4413                    state = Code;
4414                }
4415            }
4416            DoubleQuote => {
4417                if c == b'\\' {
4418                    i += 1;
4419                } else if c == b'"' {
4420                    state = Code;
4421                }
4422            }
4423            Template => {
4424                if c == b'\\' {
4425                    i += 1;
4426                } else if c == b'`' {
4427                    state = Code;
4428                }
4429            }
4430        }
4431        i += 1;
4432    }
4433
4434    true
4435}
4436
4437#[cfg(test)]
4438mod prop_tests {
4439    //! Property-based tests for the eval auto-return heuristic — the code that
4440    //! caused the worst bug in the system (silent corruption of multi-statement
4441    //! eval) and has bitten twice. These generate many JS-ish snippets and
4442    //! assert the invariants that keep eval correct.
4443    use super::should_prepend_return;
4444    use proptest::prelude::*;
4445
4446    /// A small set of non-keyword identifier-ish expressions.
4447    fn ident() -> impl Strategy<Value = String> {
4448        prop_oneof![
4449            Just("a".to_string()),
4450            Just("x".to_string()),
4451            Just("foo".to_string()),
4452            Just("window.x".to_string()),
4453            Just("document.title".to_string()),
4454            Just("obj.prop".to_string()),
4455            Just("arr[0]".to_string()),
4456            Just("localStorage".to_string()),
4457        ]
4458    }
4459
4460    /// A single bare expression: never starts with a statement keyword, has no
4461    /// top-level `;`, and contains no `return`.
4462    fn bare_expr() -> impl Strategy<Value = String> {
4463        prop_oneof![
4464            ident(),
4465            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4466            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4467            ident().prop_map(|a| format!("{a}.length")),
4468            any::<u16>().prop_map(|n| n.to_string()),
4469        ]
4470    }
4471
4472    proptest! {
4473        /// Must never panic or hang on ANY input — including malformed code,
4474        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
4475        #[test]
4476        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4477            let _ = should_prepend_return(&s);
4478        }
4479
4480        /// A single bare expression is safe to wrap with `return` → true.
4481        #[test]
4482        fn bare_expressions_are_prepended(e in bare_expr()) {
4483            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4484        }
4485
4486        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
4487        /// (else `return <expr>;` runs and the rest is silently discarded).
4488        #[test]
4489        fn semicolon_multistatement_with_return_never_prepended(
4490            setup in bare_expr(), ret in bare_expr()
4491        ) {
4492            let code = format!("{setup}; return {ret}");
4493            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4494        }
4495
4496        /// Newline-separated (ASI) explicit return must also be left as-is.
4497        #[test]
4498        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4499            let code = format!("{pre}\nreturn {ret}");
4500            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4501        }
4502
4503        /// `;` or the word `return` INSIDE a string literal must not trigger a
4504        /// false multi-statement split — a bare string is one expression.
4505        #[test]
4506        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4507            // `inner` never contains a quote, so the literal is well-formed.
4508            let code = format!("'do;not;split return {inner}'");
4509            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4510        }
4511    }
4512}
4513
4514#[cfg(test)]
4515mod tests {
4516    use super::*;
4517
4518    #[test]
4519    fn env_filter_drops_secrets_keeps_safe() {
4520        // Safe, non-secret vars pass.
4521        assert!(is_safe_env_key("HOME"));
4522        assert!(is_safe_env_key("LANG"));
4523        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
4524        assert!(is_safe_env_key("VICTAURI_PORT"));
4525        // Secret-looking vars are dropped even under a safe prefix (audit #5).
4526        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
4527        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
4528        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
4529        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
4530        // Unknown prefixes are dropped regardless.
4531        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
4532        assert!(!is_safe_env_key("RANDOM_VAR"));
4533        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
4534        // app-custom TAURI_ secrets are dropped even without a denylist hit.
4535        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
4536        // Adversarial leaks closed (audit #5 follow-up): connection strings,
4537        // passphrases, PATs, JWTs, etc. under an allowed prefix.
4538        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
4539        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
4540        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
4541        assert!(!is_safe_env_key("VICTAURI_JWT"));
4542        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
4543    }
4544
4545    #[test]
4546    fn prepend_return_bare_expressions() {
4547        assert!(should_prepend_return("document.title"));
4548        assert!(should_prepend_return("5 + 5"));
4549        assert!(should_prepend_return("\"justexpr\""));
4550        assert!(should_prepend_return("await fetch('/x')"));
4551        assert!(should_prepend_return(
4552            "document.querySelectorAll('a').length"
4553        ));
4554        assert!(should_prepend_return("x ? a : b"));
4555        // Single trailing semicolon on a bare expression is still an expression.
4556        assert!(should_prepend_return("document.title;"));
4557        // Semicolons inside strings must not be treated as boundaries.
4558        assert!(should_prepend_return("'a;b;c'"));
4559        assert!(should_prepend_return("\"x;y\".length"));
4560        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
4561        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4562    }
4563
4564    #[test]
4565    fn no_prepend_for_statement_blocks() {
4566        // The original silent-corruption cases.
4567        assert!(!should_prepend_return(
4568            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4569        ));
4570        assert!(!should_prepend_return(
4571            "window.scrollTo(0,50); return window.scrollY"
4572        ));
4573        assert!(!should_prepend_return("console.log('x'); return 123"));
4574        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4575        // Explicit return without a preceding semicolon (newline-separated).
4576        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4577    }
4578
4579    #[test]
4580    fn no_prepend_for_statement_keywords() {
4581        assert!(!should_prepend_return("return 42"));
4582        assert!(!should_prepend_return("const x = 1; return x"));
4583        assert!(!should_prepend_return("let y = 2"));
4584        assert!(!should_prepend_return("var z = 3"));
4585        assert!(!should_prepend_return("if (x) { return 1 }"));
4586        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4587        assert!(!should_prepend_return("throw new Error('x')"));
4588        assert!(!should_prepend_return("function f(){}"));
4589        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
4590    }
4591
4592    #[test]
4593    fn empty_code_no_prepend() {
4594        assert!(!should_prepend_return(""));
4595        assert!(!should_prepend_return("   "));
4596    }
4597
4598    #[test]
4599    fn envelope_unwrap_value() {
4600        assert_eq!(
4601            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4602            Ok("\"4DA\"".to_string())
4603        );
4604        assert_eq!(
4605            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4606            Ok("42".to_string())
4607        );
4608    }
4609
4610    #[test]
4611    fn envelope_unwrap_undefined_null() {
4612        assert_eq!(
4613            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4614            Ok("undefined".to_string())
4615        );
4616        assert_eq!(
4617            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4618            Ok("null".to_string())
4619        );
4620    }
4621
4622    #[test]
4623    fn envelope_unwrap_error() {
4624        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4625        assert!(r.unwrap_err().contains("boom"));
4626    }
4627
4628    #[test]
4629    fn envelope_unwrap_deeply_nested_does_not_leak() {
4630        // Build an envelope whose value is nested far deeper than serde_json's
4631        // default recursion limit (128). The full parse fails, so the slice
4632        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
4633        let mut value = String::from("0");
4634        for _ in 0..300 {
4635            value = format!("{{\"n\":{value}}}");
4636        }
4637        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4638        let out = unwrap_eval_envelope(raw).unwrap();
4639        assert!(
4640            out.starts_with(r#"{"n":"#),
4641            "deep value should be unwrapped, got: {}",
4642            &out[..out.len().min(40)]
4643        );
4644        assert!(
4645            !out.contains("__victauri_ok"),
4646            "envelope must not leak into the result"
4647        );
4648    }
4649
4650    #[test]
4651    fn js_string_simple() {
4652        assert_eq!(js_string("hello"), "\"hello\"");
4653    }
4654
4655    #[test]
4656    fn js_string_single_quotes() {
4657        let result = js_string("it's a test");
4658        assert!(result.contains("it's a test"));
4659    }
4660
4661    #[test]
4662    fn js_string_double_quotes() {
4663        let result = js_string(r#"say "hello""#);
4664        assert!(result.contains(r#"\""#));
4665    }
4666
4667    #[test]
4668    fn js_string_backslashes() {
4669        let result = js_string(r"path\to\file");
4670        assert!(result.contains(r"\\"));
4671    }
4672
4673    #[test]
4674    fn js_string_newlines_and_tabs() {
4675        let result = js_string("line1\nline2\ttab");
4676        assert!(result.contains(r"\n"));
4677        assert!(result.contains(r"\t"));
4678        assert!(!result.contains('\n'));
4679    }
4680
4681    #[test]
4682    fn js_string_null_bytes() {
4683        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
4684        let result = js_string(&input);
4685        // serde_json escapes null bytes as
4686        assert!(result.contains("\\u0000"));
4687        assert!(!result.contains('\0'));
4688    }
4689
4690    #[test]
4691    fn js_string_template_literal_injection() {
4692        let result = js_string("`${alert(1)}`");
4693        // Should not contain unescaped backticks that could break template literals
4694        // serde_json wraps in double quotes, so backticks are safe
4695        assert!(result.starts_with('"'));
4696        assert!(result.ends_with('"'));
4697    }
4698
4699    #[test]
4700    fn js_string_unicode_separators() {
4701        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
4702        // JSON strings per RFC 8259, and serde_json passes them through literally.
4703        // Since js_string is used inside JS double-quoted strings (not template
4704        // literals), they are safe in modern JS engines (ES2019+).
4705        let result = js_string("a\u{2028}b\u{2029}c");
4706        // Verify the string is valid JSON that round-trips correctly
4707        let decoded: String = serde_json::from_str(&result).unwrap();
4708        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
4709    }
4710
4711    #[test]
4712    fn js_string_empty() {
4713        assert_eq!(js_string(""), "\"\"");
4714    }
4715
4716    #[test]
4717    fn js_string_html_script_close() {
4718        // </script> in a JS string inside HTML could break out of script tags
4719        let result = js_string("</script><img onerror=alert(1)>");
4720        assert!(result.starts_with('"'));
4721        // The string is JSON-encoded; verify it round-trips safely
4722        let decoded: String = serde_json::from_str(&result).unwrap();
4723        assert_eq!(decoded, "</script><img onerror=alert(1)>");
4724    }
4725
4726    #[test]
4727    fn js_string_very_long() {
4728        let long = "a".repeat(100_000);
4729        let result = js_string(&long);
4730        assert!(result.len() >= 100_002); // quotes + content
4731    }
4732
4733    // ── URL validation tests ────────────────────────────────────────────────
4734
4735    #[test]
4736    fn url_allows_http() {
4737        assert!(validate_url("http://example.com", false).is_ok());
4738    }
4739
4740    #[test]
4741    fn url_allows_https() {
4742        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
4743    }
4744
4745    #[test]
4746    fn url_allows_http_localhost() {
4747        assert!(validate_url("http://localhost:3000", false).is_ok());
4748    }
4749
4750    #[test]
4751    fn url_blocks_file_by_default() {
4752        let err = validate_url("file:///etc/passwd", false).unwrap_err();
4753        assert!(err.contains("file"), "error should mention the file scheme");
4754    }
4755
4756    #[test]
4757    fn url_allows_file_when_opted_in() {
4758        assert!(validate_url("file:///tmp/test.html", true).is_ok());
4759    }
4760
4761    #[test]
4762    fn url_blocks_javascript() {
4763        assert!(validate_url("javascript:alert(1)", false).is_err());
4764    }
4765
4766    #[test]
4767    fn url_blocks_javascript_case_insensitive() {
4768        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
4769    }
4770
4771    #[test]
4772    fn url_blocks_data_scheme() {
4773        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
4774    }
4775
4776    #[test]
4777    fn url_blocks_vbscript() {
4778        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
4779    }
4780
4781    #[test]
4782    fn url_rejects_invalid() {
4783        assert!(validate_url("not a url at all", false).is_err());
4784    }
4785
4786    #[test]
4787    fn url_strips_control_chars() {
4788        // Control characters should be stripped, leaving a valid URL
4789        let input = format!("http://example{}com", '\0');
4790        assert!(validate_url(&input, false).is_ok());
4791    }
4792
4793    // ── CSS color sanitization tests ───────────────────────────────────────
4794
4795    #[test]
4796    fn css_color_valid_hex() {
4797        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
4798        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
4799        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
4800    }
4801
4802    #[test]
4803    fn css_color_valid_rgb() {
4804        assert_eq!(
4805            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
4806            "rgb(255, 0, 0)"
4807        );
4808        assert_eq!(
4809            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
4810            "rgba(0, 0, 0, 0.5)"
4811        );
4812    }
4813
4814    #[test]
4815    fn css_color_valid_named() {
4816        assert_eq!(sanitize_css_color("red").unwrap(), "red");
4817        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
4818    }
4819
4820    #[test]
4821    fn css_color_valid_hsl() {
4822        assert_eq!(
4823            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
4824            "hsl(120, 50%, 50%)"
4825        );
4826    }
4827
4828    #[test]
4829    fn css_color_rejects_too_long() {
4830        let long = "a".repeat(101);
4831        assert!(sanitize_css_color(&long).is_err());
4832    }
4833
4834    #[test]
4835    fn css_color_rejects_backslash_escapes() {
4836        assert!(sanitize_css_color(r"red\00").is_err());
4837        assert!(sanitize_css_color(r"\72\65\64").is_err());
4838    }
4839
4840    #[test]
4841    fn css_color_rejects_url_injection() {
4842        assert!(sanitize_css_color("url(http://evil.com)").is_err());
4843        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
4844    }
4845
4846    #[test]
4847    fn css_color_rejects_expression_injection() {
4848        assert!(sanitize_css_color("expression(alert(1))").is_err());
4849        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
4850    }
4851
4852    #[test]
4853    fn css_color_rejects_import() {
4854        assert!(sanitize_css_color("@import url(evil.css)").is_err());
4855    }
4856
4857    #[test]
4858    fn css_color_rejects_semicolons_and_braces() {
4859        assert!(sanitize_css_color("red; background: url(evil)").is_err());
4860        assert!(sanitize_css_color("red} body { color: blue").is_err());
4861    }
4862
4863    #[test]
4864    fn css_color_rejects_special_chars() {
4865        assert!(sanitize_css_color("red<script>").is_err());
4866        assert!(sanitize_css_color("red\"onload=alert").is_err());
4867        assert!(sanitize_css_color("red'onclick=alert").is_err());
4868    }
4869
4870    #[test]
4871    fn css_color_trims_whitespace() {
4872        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
4873    }
4874
4875    #[test]
4876    fn css_color_empty_string() {
4877        assert_eq!(sanitize_css_color("").unwrap(), "");
4878    }
4879}