Skip to main content

victauri_plugin/mcp/
mod.rs

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