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