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