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