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, ghost_ipc_projection_js, js_string, json_result, json_truthy, missing_param,
40    sanitize_css_color, sanitize_injected_css, tool_disabled, tool_error, tool_error_with_hint,
41    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 ghost commands. Returns TWO separate lists: `frontend_only` = the true ghosts (commands invoked from the frontend that have NO backend handler — likely typos/bugs), and `registry_only` = informational (commands registered in the backend but never invoked from the frontend this session). Reads the JS-side IPC interception log, which ACCUMULATES every command seen this session (including earlier test/probe traffic) — so a `frontend_only` result reflects what was invoked at runtime, not necessarily a real frontend bug. For a clean signal, either (a) pass `since_ms` (e.g. 5000) to scope to commands invoked in the last N ms — non-destructive, the preferred per-test pattern: invoke the suspect action, then call this with `since_ms`; or (b) call `logs {action:'clear'}` first, then exercise the app, then run this.",
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(&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                if let Some(threshold) = params.slow_threshold_ms {
2823                    stats.retain(|s| s.avg_ms >= threshold);
2824                }
2825                let result = serde_json::json!({
2826                    "commands": stats,
2827                    "total_commands_profiled": self.state.command_timings.all_stats().len(),
2828                    "slow_threshold_ms": params.slow_threshold_ms,
2829                });
2830                json_result(&result)
2831            }
2832            IntrospectAction::Coverage => {
2833                let registered: Vec<String> = self
2834                    .state
2835                    .registry
2836                    .list()
2837                    .iter()
2838                    .map(|c| c.name.clone())
2839                    .collect();
2840
2841                let code = "return window.__VICTAURI__?.getIpcLog()";
2842                let invoked: std::collections::HashSet<String> = match self
2843                    .eval_with_return(code, params.webview_label.as_deref())
2844                    .await
2845                {
2846                    Ok(json_str) => {
2847                        if let Ok(entries) =
2848                            serde_json::from_str::<Vec<serde_json::Value>>(&json_str)
2849                        {
2850                            entries
2851                                .iter()
2852                                .filter_map(|e| e.get("command").and_then(|c| c.as_str()))
2853                                .map(String::from)
2854                                .collect()
2855                        } else {
2856                            std::collections::HashSet::new()
2857                        }
2858                    }
2859                    Err(_) => std::collections::HashSet::new(),
2860                };
2861
2862                let uncovered: Vec<&String> = registered
2863                    .iter()
2864                    .filter(|cmd| !invoked.contains(cmd.as_str()))
2865                    .collect();
2866
2867                let coverage_pct = if registered.is_empty() {
2868                    100.0
2869                } else {
2870                    let covered = registered.len() - uncovered.len();
2871                    (covered as f64 / registered.len() as f64) * 100.0
2872                };
2873
2874                let result = serde_json::json!({
2875                    "registered_commands": registered.len(),
2876                    "invoked_commands": invoked.len(),
2877                    "coverage_pct": (coverage_pct * 10.0).round() / 10.0,
2878                    "uncovered": uncovered,
2879                    "invoked_not_registered": invoked.iter()
2880                        .filter(|cmd| !registered.contains(cmd))
2881                        .collect::<Vec<_>>(),
2882                });
2883                json_result(&result)
2884            }
2885            IntrospectAction::ContractRecord => {
2886                let Some(command) = params.command else {
2887                    return missing_param("command", "contract_record");
2888                };
2889                // contract_record invokes the command with caller-supplied args, so
2890                // it must honour the same allow/blocklist as invoke_command (audit #30).
2891                if !self.state.privacy.is_invoke_allowed(&command)
2892                    || !self.state.privacy.is_command_allowed(&command)
2893                {
2894                    return tool_error(format!(
2895                        "command '{command}' is blocked by privacy configuration"
2896                    ));
2897                }
2898                let args_json = params.args.unwrap_or(serde_json::json!({}));
2899                let args_str =
2900                    serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
2901                let code = format!(
2902                    "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2903                    js_string(&command)
2904                );
2905                match self
2906                    .eval_with_return(&code, params.webview_label.as_deref())
2907                    .await
2908                {
2909                    Ok(result_str) => {
2910                        let value: serde_json::Value = serde_json::from_str(&result_str)
2911                            .unwrap_or(serde_json::Value::String(result_str.clone()));
2912                        let shape = crate::introspection::JsonShape::from_value(&value);
2913                        let sample = if result_str.len() > 4096 {
2914                            format!("{}...(truncated)", &result_str[..4096])
2915                        } else {
2916                            result_str
2917                        };
2918                        let baseline = crate::introspection::ContractBaseline {
2919                            command: command.clone(),
2920                            args: args_json,
2921                            shape: shape.clone(),
2922                            sample,
2923                            recorded_at: chrono_now(),
2924                        };
2925                        self.state.contract_store.record(baseline);
2926                        let result = serde_json::json!({
2927                            "recorded": true,
2928                            "command": command,
2929                            "shape_type": shape.type_name(),
2930                        });
2931                        json_result(&result)
2932                    }
2933                    Err(e) => tool_error(format!(
2934                        "failed to invoke '{command}' for contract recording: {e}"
2935                    )),
2936                }
2937            }
2938            IntrospectAction::ContractCheck => {
2939                let baselines = self.state.contract_store.all();
2940                if baselines.is_empty() {
2941                    return json_result(&serde_json::json!({
2942                        "checked": 0,
2943                        "message": "no contract baselines recorded — use contract_record first",
2944                    }));
2945                }
2946                let mut results = Vec::new();
2947                for baseline in &baselines {
2948                    // Re-checking a baseline re-invokes the command; honour the
2949                    // allow/blocklist in case it changed since recording (audit #30).
2950                    if !self.state.privacy.is_invoke_allowed(&baseline.command)
2951                        || !self.state.privacy.is_command_allowed(&baseline.command)
2952                    {
2953                        continue;
2954                    }
2955                    let args_str =
2956                        serde_json::to_string(&baseline.args).unwrap_or_else(|_| "{}".to_string());
2957                    let code = format!(
2958                        "return window.__TAURI_INTERNALS__.invoke({}, {args_str})",
2959                        js_string(&baseline.command)
2960                    );
2961                    match self
2962                        .eval_with_return(&code, params.webview_label.as_deref())
2963                        .await
2964                    {
2965                        Ok(result_str) => {
2966                            let value: serde_json::Value = serde_json::from_str(&result_str)
2967                                .unwrap_or(serde_json::Value::String(result_str));
2968                            let current_shape = crate::introspection::JsonShape::from_value(&value);
2969                            let drift = crate::introspection::diff_shapes(
2970                                &baseline.shape,
2971                                &current_shape,
2972                                &baseline.command,
2973                            );
2974                            results.push(drift);
2975                        }
2976                        Err(e) => {
2977                            results.push(crate::introspection::ContractDrift {
2978                                command: baseline.command.clone(),
2979                                new_fields: Vec::new(),
2980                                removed_fields: Vec::new(),
2981                                type_changes: Vec::new(),
2982                                shape_matches: false,
2983                            });
2984                            tracing::warn!(
2985                                command = %baseline.command,
2986                                error = %e,
2987                                "contract check invocation failed"
2988                            );
2989                        }
2990                    }
2991                }
2992                let passing = results.iter().filter(|r| r.shape_matches).count();
2993                let result = serde_json::json!({
2994                    "checked": results.len(),
2995                    "passing": passing,
2996                    "failing": results.len() - passing,
2997                    "contracts": results,
2998                });
2999                json_result(&result)
3000            }
3001            IntrospectAction::ContractList => {
3002                let baselines = self.state.contract_store.all();
3003                let result = serde_json::json!({
3004                    "count": baselines.len(),
3005                    "baselines": baselines.iter().map(|b| serde_json::json!({
3006                        "command": b.command,
3007                        "shape_type": b.shape.type_name(),
3008                        "recorded_at": b.recorded_at,
3009                    })).collect::<Vec<_>>(),
3010                });
3011                json_result(&result)
3012            }
3013            IntrospectAction::ContractClear => {
3014                let cleared = self.state.contract_store.clear();
3015                json_result(&serde_json::json!({
3016                    "cleared": cleared,
3017                }))
3018            }
3019            IntrospectAction::StartupTiming => {
3020                let phases = self.state.startup_timeline.report();
3021                let result = serde_json::json!({
3022                    "phases": phases,
3023                    "total_ms": self.state.startup_timeline.total_ms(),
3024                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3025                });
3026                json_result(&result)
3027            }
3028            IntrospectAction::Capabilities => {
3029                let config = self.bridge.tauri_config();
3030                let live_windows = self.bridge.list_window_labels();
3031
3032                let result = serde_json::json!({
3033                    "app": {
3034                        "identifier": config.get("identifier"),
3035                        "product_name": config.get("product_name"),
3036                        "version": config.get("version"),
3037                    },
3038                    "security": config.get("security"),
3039                    "configured_windows": config.get("windows"),
3040                    "live_windows": live_windows,
3041                    "configured_plugins": config.get("plugins"),
3042                    "victauri": {
3043                        "registered_commands": self.state.registry.list().len(),
3044                        "redaction_enabled": self.state.privacy.redaction_enabled,
3045                        "privacy_profile": format!("{:?}", self.state.privacy.profile),
3046                        "disabled_tools": &self.state.privacy.disabled_tools,
3047                    },
3048                });
3049                json_result(&result)
3050            }
3051            #[allow(unused_variables)]
3052            IntrospectAction::DbHealth => {
3053                #[cfg(feature = "sqlite")]
3054                {
3055                    let db_path = params.db_path.clone();
3056                    match self.run_db_health(db_path.as_deref()).await {
3057                        Ok(health) => json_result(&health),
3058                        Err(e) => tool_error(format!("db_health failed: {e}")),
3059                    }
3060                }
3061                #[cfg(not(feature = "sqlite"))]
3062                {
3063                    tool_error("SQLite support not compiled in — enable the `sqlite` feature")
3064                }
3065            }
3066            IntrospectAction::PluginState => {
3067                let recording_active = self.state.recorder.is_recording();
3068                let recording_events = self.state.recorder.event_count();
3069                let result = serde_json::json!({
3070                    "event_log": {
3071                        "size": self.state.event_log.len(),
3072                        "capacity": self.state.event_log.capacity(),
3073                    },
3074                    "registry": {
3075                        "commands_registered": self.state.registry.list().len(),
3076                    },
3077                    "recording": {
3078                        "active": recording_active,
3079                        "events_captured": recording_events,
3080                    },
3081                    "faults": {
3082                        "active_rules": self.state.fault_registry.list().len(),
3083                    },
3084                    "contracts": {
3085                        "baselines_recorded": self.state.contract_store.all().len(),
3086                    },
3087                    "timings": {
3088                        "commands_profiled": self.state.command_timings.all_stats().len(),
3089                    },
3090                    "event_bus": {
3091                        "captured_events": self.state.event_bus.len(),
3092                    },
3093                    "tasks": {
3094                        "total": self.state.task_tracker.list().len(),
3095                        "active": self.state.task_tracker.active_count(),
3096                    },
3097                    "tool_invocations": self.state.tool_invocations.load(Ordering::Relaxed),
3098                    "uptime_secs": self.state.started_at.elapsed().as_secs(),
3099                    "port": self.state.port.load(std::sync::atomic::Ordering::Relaxed),
3100                });
3101                json_result(&result)
3102            }
3103            IntrospectAction::Processes => {
3104                let pid = std::process::id();
3105                let uptime = self.state.started_at.elapsed();
3106                let children = crate::introspection::enumerate_child_processes();
3107                let host_memory = crate::memory::current_stats();
3108
3109                let result = serde_json::json!({
3110                    "host": {
3111                        "pid": pid,
3112                        "uptime_secs": uptime.as_secs(),
3113                        "platform": std::env::consts::OS,
3114                        "arch": std::env::consts::ARCH,
3115                        "memory": host_memory,
3116                    },
3117                    "children": children.iter().map(|c| serde_json::json!({
3118                        "pid": c.pid,
3119                        "name": c.name,
3120                        "memory_bytes": c.memory_bytes,
3121                    })).collect::<Vec<_>>(),
3122                    "child_count": children.len(),
3123                    "total_child_memory_bytes": children.iter().filter_map(|c| c.memory_bytes).sum::<u64>(),
3124                });
3125                json_result(&result)
3126            }
3127            IntrospectAction::PluginTasks => {
3128                let tasks = self.state.task_tracker.list();
3129                let active = self.state.task_tracker.active_count();
3130                let result = serde_json::json!({
3131                    "total": tasks.len(),
3132                    "active": active,
3133                    "finished": tasks.len() - active,
3134                    "tasks": tasks,
3135                });
3136                json_result(&result)
3137            }
3138            IntrospectAction::EventBus => {
3139                let tauri_events = self.state.event_bus.events();
3140                let app_events = self.state.event_log.snapshot();
3141                let result = serde_json::json!({
3142                    "tauri_events": {
3143                        "count": tauri_events.len(),
3144                        "events": tauri_events,
3145                    },
3146                    "app_events": {
3147                        "count": app_events.len(),
3148                        "capacity": self.state.event_log.capacity(),
3149                        "events": app_events,
3150                    },
3151                });
3152                json_result(&result)
3153            }
3154            IntrospectAction::EventBusClear => {
3155                let tauri_cleared = self.state.event_bus.clear();
3156                self.state.event_log.clear();
3157                json_result(&serde_json::json!({
3158                    "tauri_events_cleared": tauri_cleared,
3159                    "app_events_cleared": true,
3160                }))
3161            }
3162        }
3163    }
3164
3165    // ── Fault Injection / Chaos Engineering ──────────────────────────────────
3166
3167    #[tool(
3168        description = "Probe a backend command handler under failure by faulting it for chaos engineering. \
3169            Simulate slow commands, backend errors, dropped responses, and corrupted data. \
3170            SCOPE: faults apply ONLY to commands you run via this server's `invoke_command` tool — \
3171            they do NOT intercept the app's real user-driven IPC (window.__TAURI_INTERNALS__.invoke), \
3172            which runs below the layer Victauri can reach. Use this to test a handler's error path when \
3173            YOU drive it; it does not reproduce a failure a user clicking the UI would see.\n\n\
3174            Actions:\n\
3175            - `inject`: Add a fault rule (requires `command`, `fault_type`). Optional: `delay_ms`, `error_message`, `max_triggers`.\n\
3176            - `list`: List all active fault injection rules.\n\
3177            - `clear`: Remove a specific fault rule (requires `command`).\n\
3178            - `clear_all`: Remove all fault rules.",
3179        annotations(
3180            read_only_hint = false,
3181            destructive_hint = true,
3182            idempotent_hint = false,
3183            open_world_hint = false
3184        )
3185    )]
3186    async fn fault(&self, Parameters(params): Parameters<FaultParams>) -> CallToolResult {
3187        if !self.state.privacy.is_tool_enabled("fault") {
3188            return tool_disabled("fault");
3189        }
3190
3191        match params.action {
3192            FaultAction::Inject => {
3193                let Some(command) = params.command else {
3194                    return missing_param("command", "inject");
3195                };
3196                let Some(fault_kind) = params.fault_type else {
3197                    return missing_param("fault_type", "inject");
3198                };
3199                let fault_type = match fault_kind {
3200                    FaultKind::Delay => {
3201                        let delay_ms = params.delay_ms.unwrap_or(1000);
3202                        crate::introspection::FaultType::Delay { delay_ms }
3203                    }
3204                    FaultKind::Error => {
3205                        let message = params
3206                            .error_message
3207                            .unwrap_or_else(|| "injected fault".to_string());
3208                        crate::introspection::FaultType::Error { message }
3209                    }
3210                    FaultKind::Drop => crate::introspection::FaultType::Drop,
3211                    FaultKind::Corrupt => crate::introspection::FaultType::Corrupt,
3212                };
3213                let config = crate::introspection::FaultConfig {
3214                    command: command.clone(),
3215                    fault_type: fault_type.clone(),
3216                    trigger_count: 0,
3217                    max_triggers: params.max_triggers.unwrap_or(0),
3218                    created_at: std::time::Instant::now(),
3219                };
3220                self.state.fault_registry.inject(config);
3221                let result = serde_json::json!({
3222                    "injected": true,
3223                    "command": command,
3224                    "fault_type": fault_type,
3225                    "max_triggers": params.max_triggers.unwrap_or(0),
3226                });
3227                json_result(&result)
3228            }
3229            FaultAction::List => {
3230                let faults = self.state.fault_registry.list();
3231                let result = serde_json::json!({
3232                    "count": faults.len(),
3233                    "faults": faults.iter().map(|f| serde_json::json!({
3234                        "command": f.command,
3235                        "fault_type": f.fault_type,
3236                        "trigger_count": f.trigger_count,
3237                        "max_triggers": f.max_triggers,
3238                    })).collect::<Vec<_>>(),
3239                });
3240                json_result(&result)
3241            }
3242            FaultAction::Clear => {
3243                let Some(command) = params.command else {
3244                    return missing_param("command", "clear");
3245                };
3246                let removed = self.state.fault_registry.clear(&command);
3247                json_result(&serde_json::json!({
3248                    "removed": removed,
3249                    "command": command,
3250                }))
3251            }
3252            FaultAction::ClearAll => {
3253                let removed = self.state.fault_registry.clear_all();
3254                json_result(&serde_json::json!({
3255                    "removed": removed,
3256                }))
3257            }
3258        }
3259    }
3260
3261    // ── Cross-Layer Explanation ────────────────────────────────────────────
3262
3263    #[tool(
3264        description = "Correlate recent activity across all layers into a coherent narrative. \
3265            CDP shows raw events per layer; Victauri correlates IPC + DOM + console + network \
3266            + window events across the Rust backend and webview simultaneously.\n\n\
3267            Actions:\n\
3268            - `summary`: High-level activity summary for the last N seconds (default 30). \
3269              Counts IPC calls, DOM mutations, console entries, network requests, errors.\n\
3270            - `last_action`: Correlate the most recent burst of events into a causal timeline \
3271              (e.g. 'IPC call → DOM update → console.log').\n\
3272            - `diff`: What changed in the last N seconds — event counts, errors, new IPC commands.",
3273        annotations(
3274            read_only_hint = true,
3275            destructive_hint = false,
3276            idempotent_hint = true,
3277            open_world_hint = false
3278        )
3279    )]
3280    async fn explain(&self, Parameters(params): Parameters<ExplainParams>) -> CallToolResult {
3281        if !self.state.privacy.is_tool_enabled("explain") {
3282            return tool_disabled("explain");
3283        }
3284
3285        match params.action {
3286            ExplainAction::Summary => {
3287                let secs = params.seconds.unwrap_or(30);
3288                let since = chrono::Utc::now()
3289                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3290                let events = self.state.event_log.since(since);
3291
3292                let mut ipc_count = 0u64;
3293                let mut dom_mutations = 0u64;
3294                let mut state_changes = 0u64;
3295                let mut console_count = 0u64;
3296                let mut window_events = 0u64;
3297                let mut interactions = 0u64;
3298                let mut top_commands: HashMap<String, u64> = HashMap::new();
3299                let mut errors: Vec<String> = Vec::new();
3300
3301                for event in &events {
3302                    match event {
3303                        victauri_core::AppEvent::Ipc(call) => {
3304                            ipc_count += 1;
3305                            *top_commands.entry(call.command.clone()).or_insert(0) += 1;
3306                            if let victauri_core::IpcResult::Err(e) = &call.result {
3307                                errors.push(format!("IPC {}: {e}", call.command));
3308                            }
3309                        }
3310                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3311                            dom_mutations += u64::from(*mutation_count)
3312                        }
3313                        victauri_core::AppEvent::StateChange { .. } => state_changes += 1,
3314                        victauri_core::AppEvent::Console { level, message, .. } => {
3315                            console_count += 1;
3316                            if level == "error" {
3317                                errors.push(format!("console.error: {message}"));
3318                            }
3319                        }
3320                        victauri_core::AppEvent::WindowEvent { .. } => window_events += 1,
3321                        victauri_core::AppEvent::DomInteraction { .. } => interactions += 1,
3322                        _ => {}
3323                    }
3324                }
3325
3326                let mut sorted_cmds: Vec<_> = top_commands.into_iter().collect();
3327                sorted_cmds.sort_by_key(|b| std::cmp::Reverse(b.1));
3328                let top: Vec<_> = sorted_cmds.iter().take(5).collect();
3329
3330                let narrative = format!(
3331                    "{ipc_count} IPC call{} in the last {secs}s{}. \
3332                     {dom_mutations} DOM mutation{}, {interactions} interaction{}, \
3333                     {console_count} console message{}, {window_events} window event{}. {}.",
3334                    if ipc_count == 1 { "" } else { "s" },
3335                    if top.is_empty() {
3336                        String::new()
3337                    } else {
3338                        format!(
3339                            ", dominated by {}",
3340                            top.iter()
3341                                .map(|(cmd, n)| format!("{cmd} ({n}x)"))
3342                                .collect::<Vec<_>>()
3343                                .join(", ")
3344                        )
3345                    },
3346                    if dom_mutations == 1 { "" } else { "s" },
3347                    if interactions == 1 { "" } else { "s" },
3348                    if console_count == 1 { "" } else { "s" },
3349                    if window_events == 1 { "" } else { "s" },
3350                    if errors.is_empty() {
3351                        "No errors".to_string()
3352                    } else {
3353                        format!(
3354                            "{} error{}",
3355                            errors.len(),
3356                            if errors.len() == 1 { "" } else { "s" }
3357                        )
3358                    },
3359                );
3360
3361                let result = serde_json::json!({
3362                    "time_window_secs": secs,
3363                    "total_events": events.len(),
3364                    "ipc_calls": ipc_count,
3365                    "dom_mutations": dom_mutations,
3366                    "state_changes": state_changes,
3367                    "console_messages": console_count,
3368                    "window_events": window_events,
3369                    "interactions": interactions,
3370                    "top_commands": sorted_cmds.iter().take(5).map(|(cmd, n)| {
3371                        serde_json::json!({"command": cmd, "count": n})
3372                    }).collect::<Vec<_>>(),
3373                    "errors": errors,
3374                    "narrative": narrative,
3375                });
3376                json_result(&result)
3377            }
3378            ExplainAction::LastAction => {
3379                let secs = params.seconds.unwrap_or(5);
3380                let since = chrono::Utc::now()
3381                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3382                let events = self.state.event_log.since(since);
3383
3384                let timeline: Vec<serde_json::Value> = events
3385                    .iter()
3386                    .filter(|e| !e.is_internal())
3387                    .map(|event| match event {
3388                        victauri_core::AppEvent::Ipc(call) => serde_json::json!({
3389                            "time": call.timestamp.to_rfc3339_opts(
3390                                chrono::SecondsFormat::Millis, true
3391                            ),
3392                            "type": "ipc",
3393                            "detail": format!(
3394                                "{} {} ({}ms)",
3395                                call.command,
3396                                call.result,
3397                                call.duration_ms.unwrap_or(0)
3398                            ),
3399                        }),
3400                        victauri_core::AppEvent::DomMutation {
3401                            timestamp,
3402                            mutation_count,
3403                            webview_label,
3404                        } => serde_json::json!({
3405                            "time": timestamp.to_rfc3339_opts(
3406                                chrono::SecondsFormat::Millis, true
3407                            ),
3408                            "type": "dom_mutation",
3409                            "detail": format!(
3410                                "{mutation_count} element{} updated in {webview_label}",
3411                                if *mutation_count == 1 { "" } else { "s" }
3412                            ),
3413                        }),
3414                        victauri_core::AppEvent::DomInteraction {
3415                            timestamp,
3416                            action,
3417                            selector,
3418                            ..
3419                        } => serde_json::json!({
3420                            "time": timestamp.to_rfc3339_opts(
3421                                chrono::SecondsFormat::Millis, true
3422                            ),
3423                            "type": "interaction",
3424                            "detail": format!("{action} on {selector}"),
3425                        }),
3426                        victauri_core::AppEvent::StateChange {
3427                            timestamp,
3428                            key,
3429                            caused_by,
3430                        } => serde_json::json!({
3431                            "time": timestamp.to_rfc3339_opts(
3432                                chrono::SecondsFormat::Millis, true
3433                            ),
3434                            "type": "state_change",
3435                            "detail": format!(
3436                                "{key} changed{}",
3437                                caused_by.as_ref().map_or(String::new(), |c| format!(" (by {c})"))
3438                            ),
3439                        }),
3440                        victauri_core::AppEvent::Console {
3441                            timestamp,
3442                            level,
3443                            message,
3444                        } => serde_json::json!({
3445                            "time": timestamp.to_rfc3339_opts(
3446                                chrono::SecondsFormat::Millis, true
3447                            ),
3448                            "type": "console",
3449                            "detail": format!("console.{level}: {message}"),
3450                        }),
3451                        victauri_core::AppEvent::WindowEvent {
3452                            timestamp,
3453                            label,
3454                            event,
3455                        } => serde_json::json!({
3456                            "time": timestamp.to_rfc3339_opts(
3457                                chrono::SecondsFormat::Millis, true
3458                            ),
3459                            "type": "window_event",
3460                            "detail": format!("{event} on window '{label}'"),
3461                        }),
3462                        _ => serde_json::json!({
3463                            "time": event.timestamp().to_rfc3339_opts(
3464                                chrono::SecondsFormat::Millis, true
3465                            ),
3466                            "type": "other",
3467                            "detail": "unknown event type",
3468                        }),
3469                    })
3470                    .collect();
3471
3472                let narrative = if timeline.is_empty() {
3473                    format!("No activity in the last {secs}s.")
3474                } else {
3475                    let parts: Vec<String> = timeline
3476                        .iter()
3477                        .filter_map(|e| e.get("detail").and_then(|d| d.as_str()))
3478                        .map(String::from)
3479                        .collect();
3480                    parts.join(" → ")
3481                };
3482
3483                let result = serde_json::json!({
3484                    "time_window_secs": secs,
3485                    "event_count": timeline.len(),
3486                    "timeline": timeline,
3487                    "narrative": narrative,
3488                });
3489                json_result(&result)
3490            }
3491            ExplainAction::Diff => {
3492                let secs = params.seconds.unwrap_or(10);
3493                let since = chrono::Utc::now()
3494                    - chrono::TimeDelta::try_seconds(secs as i64).unwrap_or_default();
3495                let events = self.state.event_log.since(since);
3496
3497                let mut ipc_commands: Vec<String> = Vec::new();
3498                let mut dom_changes = 0u64;
3499                let mut error_count = 0u64;
3500                let mut interaction_count = 0u64;
3501                let mut console_messages = 0u64;
3502
3503                for event in &events {
3504                    if event.is_internal() {
3505                        continue;
3506                    }
3507                    match event {
3508                        victauri_core::AppEvent::Ipc(call) => {
3509                            ipc_commands.push(call.command.clone());
3510                            if matches!(call.result, victauri_core::IpcResult::Err(_)) {
3511                                error_count += 1;
3512                            }
3513                        }
3514                        victauri_core::AppEvent::DomMutation { mutation_count, .. } => {
3515                            dom_changes += u64::from(*mutation_count)
3516                        }
3517                        victauri_core::AppEvent::DomInteraction { .. } => {
3518                            interaction_count += 1;
3519                        }
3520                        victauri_core::AppEvent::Console { level, .. } => {
3521                            console_messages += 1;
3522                            if level == "error" {
3523                                error_count += 1;
3524                            }
3525                        }
3526                        _ => {}
3527                    }
3528                }
3529
3530                ipc_commands.dedup();
3531
3532                let result = serde_json::json!({
3533                    "since": since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3534                    "time_window_secs": secs,
3535                    "total_events": events.len(),
3536                    "ipc_calls_made": ipc_commands.len(),
3537                    "unique_commands": ipc_commands,
3538                    "dom_elements_changed": dom_changes,
3539                    "interactions": interaction_count,
3540                    "console_messages": console_messages,
3541                    "errors": error_count,
3542                });
3543                json_result(&result)
3544            }
3545        }
3546    }
3547}
3548
3549impl VictauriMcpHandler {
3550    /// Create a new handler backed by the given state and webview bridge.
3551    pub fn new(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> Self {
3552        Self {
3553            state,
3554            bridge,
3555            subscriptions: Arc::new(Mutex::new(HashSet::new())),
3556            bridge_checked: Arc::new(AtomicBool::new(false)),
3557            probed_labels: Arc::new(Mutex::new(HashSet::new())),
3558            timed_out_labels: Arc::new(Mutex::new(HashSet::new())),
3559        }
3560    }
3561
3562    pub(crate) fn is_tool_enabled(&self, name: &str) -> bool {
3563        self.state.privacy.is_tool_enabled(name)
3564    }
3565
3566    pub(crate) async fn execute_tool(
3567        &self,
3568        name: &str,
3569        args: serde_json::Value,
3570    ) -> Result<CallToolResult, rest::ToolCallError> {
3571        // Centralized authorization: resolve the canonical `tool.action` capability
3572        // and gate on it BEFORE dispatch, so every compound action is checked
3573        // uniformly (not just the ones whose handler remembers to). See `authz`.
3574        let capability = authz::canonical_capability(name, &args);
3575        if !self.state.privacy.is_call_allowed(name, &capability) {
3576            return Ok(tool_disabled(&capability));
3577        }
3578        self.state.tool_invocations.fetch_add(1, Ordering::Relaxed);
3579        let start = std::time::Instant::now();
3580        tracing::debug!(tool = %name, "REST tool invocation started");
3581
3582        let result = match name {
3583            "eval_js" => {
3584                let p: EvalJsParams = Self::parse_args(args)?;
3585                self.eval_js(Parameters(p)).await
3586            }
3587            "dom_snapshot" => {
3588                let p: SnapshotParams = Self::parse_args(args)?;
3589                self.dom_snapshot(Parameters(p)).await
3590            }
3591            "find_elements" => {
3592                let p: FindElementsParams = Self::parse_args(args)?;
3593                self.find_elements(Parameters(p)).await
3594            }
3595            "invoke_command" => {
3596                let p: InvokeCommandParams = Self::parse_args(args)?;
3597                self.invoke_command(Parameters(p)).await
3598            }
3599            "screenshot" => {
3600                let p: ScreenshotParams = Self::parse_args(args)?;
3601                self.screenshot(Parameters(p)).await
3602            }
3603            "verify_state" => {
3604                let p: VerifyStateParams = Self::parse_args(args)?;
3605                self.verify_state(Parameters(p)).await
3606            }
3607            "detect_ghost_commands" => {
3608                let p: GhostCommandParams = Self::parse_args(args)?;
3609                self.detect_ghost_commands(Parameters(p)).await
3610            }
3611            "check_ipc_integrity" => {
3612                let p: IpcIntegrityParams = Self::parse_args(args)?;
3613                self.check_ipc_integrity(Parameters(p)).await
3614            }
3615            "wait_for" => {
3616                let p: WaitForParams = Self::parse_args(args)?;
3617                self.wait_for(Parameters(p)).await
3618            }
3619            "assert_semantic" => {
3620                let p: SemanticAssertParams = Self::parse_args(args)?;
3621                self.assert_semantic(Parameters(p)).await
3622            }
3623            "resolve_command" => {
3624                let p: ResolveCommandParams = Self::parse_args(args)?;
3625                self.resolve_command(Parameters(p)).await
3626            }
3627            "get_registry" => {
3628                let p: RegistryParams = Self::parse_args(args)?;
3629                self.get_registry(Parameters(p)).await
3630            }
3631            "app_state" => {
3632                let p: AppStateParams = Self::parse_args(args)?;
3633                self.app_state(Parameters(p)).await
3634            }
3635            "get_memory_stats" => self.get_memory_stats().await,
3636            "get_plugin_info" => self.get_plugin_info().await,
3637            "get_diagnostics" => {
3638                let p: DiagnosticsParams = Self::parse_args(args)?;
3639                self.get_diagnostics(Parameters(p)).await
3640            }
3641            "app_info" => self.app_info().await,
3642            "list_app_dir" => {
3643                let p: ListAppDirParams = Self::parse_args(args)?;
3644                self.list_app_dir(Parameters(p)).await
3645            }
3646            "read_app_file" => {
3647                let p: ReadAppFileParams = Self::parse_args(args)?;
3648                self.read_app_file(Parameters(p)).await
3649            }
3650            "query_db" => {
3651                let p: QueryDbParams = Self::parse_args(args)?;
3652                self.query_db(Parameters(p)).await
3653            }
3654            "interact" => {
3655                let p: InteractParams = Self::parse_args(args)?;
3656                self.interact(Parameters(p)).await
3657            }
3658            "input" => {
3659                let p: InputParams = Self::parse_args(args)?;
3660                self.input(Parameters(p)).await
3661            }
3662            "window" => {
3663                let p: WindowParams = Self::parse_args(args)?;
3664                self.window(Parameters(p)).await
3665            }
3666            "storage" => {
3667                let p: StorageParams = Self::parse_args(args)?;
3668                self.storage(Parameters(p)).await
3669            }
3670            "navigate" => {
3671                let p: NavigateParams = Self::parse_args(args)?;
3672                self.navigate(Parameters(p)).await
3673            }
3674            "recording" => {
3675                let p: RecordingParams = Self::parse_args(args)?;
3676                self.recording(Parameters(p)).await
3677            }
3678            "inspect" => {
3679                let p: InspectParams = Self::parse_args(args)?;
3680                self.inspect(Parameters(p)).await
3681            }
3682            "css" => {
3683                let p: CssParams = Self::parse_args(args)?;
3684                self.css(Parameters(p)).await
3685            }
3686            "route" => {
3687                let p: RouteParams = Self::parse_args(args)?;
3688                self.route(Parameters(p)).await
3689            }
3690            "trace" => {
3691                let p: TraceParams = Self::parse_args(args)?;
3692                self.trace(Parameters(p)).await
3693            }
3694            "animation" => {
3695                let p: AnimationParams = Self::parse_args(args)?;
3696                self.animation(Parameters(p)).await
3697            }
3698            "logs" => {
3699                let p: LogsParams = Self::parse_args(args)?;
3700                self.logs(Parameters(p)).await
3701            }
3702            "introspect" => {
3703                let p: IntrospectParams = Self::parse_args(args)?;
3704                self.introspect(Parameters(p)).await
3705            }
3706            "fault" => {
3707                let p: FaultParams = Self::parse_args(args)?;
3708                self.fault(Parameters(p)).await
3709            }
3710            "explain" => {
3711                let p: ExplainParams = Self::parse_args(args)?;
3712                self.explain(Parameters(p)).await
3713            }
3714            _ => return Err(rest::ToolCallError::UnknownTool(name.to_string())),
3715        };
3716
3717        let elapsed = start.elapsed();
3718        tracing::debug!(
3719            tool = %name,
3720            elapsed_ms = elapsed.as_millis() as u64,
3721            "REST tool invocation completed"
3722        );
3723
3724        if self.state.privacy.redaction_enabled {
3725            Ok(Self::redact_result(result, &self.state.privacy))
3726        } else {
3727            Ok(result)
3728        }
3729    }
3730
3731    fn parse_args<T: serde::de::DeserializeOwned>(
3732        args: serde_json::Value,
3733    ) -> Result<T, rest::ToolCallError> {
3734        serde_json::from_value(args).map_err(|e| rest::ToolCallError::InvalidParams(e.to_string()))
3735    }
3736
3737    fn redact_result(
3738        mut result: CallToolResult,
3739        privacy: &crate::privacy::PrivacyConfig,
3740    ) -> CallToolResult {
3741        for item in &mut result.content {
3742            if let RawContent::Text(ref mut tc) = item.raw {
3743                tc.text = privacy.redact_output(&tc.text);
3744            }
3745        }
3746        result
3747    }
3748
3749    fn resolve_app_dir(&self, dir: Option<AppDir>) -> Result<std::path::PathBuf, String> {
3750        match dir.unwrap_or(AppDir::Data) {
3751            AppDir::Data => self.bridge.app_data_dir(),
3752            AppDir::Config => self.bridge.app_config_dir(),
3753            AppDir::Log => self.bridge.app_log_dir(),
3754            AppDir::LocalData => self.bridge.app_local_data_dir(),
3755        }
3756    }
3757
3758    /// Lexical (pre-existence) traversal guard for a user-supplied sub-path.
3759    ///
3760    /// Rejects absolute paths and any component that is `..` BEFORE the path is
3761    /// canonicalized. This is necessary because [`Self::safe_within`] relies on
3762    /// `canonicalize`, which errors on non-existent paths — so a traversal
3763    /// attempt against a missing target would otherwise be reported as
3764    /// "not found" (an info-leak oracle) rather than as traversal.
3765    fn lexical_safe(sub: &std::path::Path) -> Result<(), String> {
3766        use std::path::Component;
3767        if sub.is_absolute() {
3768            return Err("path traversal not allowed: absolute paths are rejected".to_string());
3769        }
3770        for component in sub.components() {
3771            match component {
3772                Component::ParentDir => {
3773                    return Err("path traversal not allowed: '..' is rejected".to_string());
3774                }
3775                Component::Prefix(_) | Component::RootDir => {
3776                    return Err(
3777                        "path traversal not allowed: absolute paths are rejected".to_string()
3778                    );
3779                }
3780                Component::CurDir | Component::Normal(_) => {}
3781            }
3782        }
3783        Ok(())
3784    }
3785
3786    fn safe_within(base: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
3787        let canon_base = std::fs::canonicalize(base)
3788            .map_err(|e| format!("cannot resolve base directory: {e}"))?;
3789        let canon_target = std::fs::canonicalize(target)
3790            .map_err(|e| format!("cannot resolve target path: {e}"))?;
3791        if !canon_target.starts_with(&canon_base) {
3792            return Err("path traversal not allowed".to_string());
3793        }
3794        Ok(())
3795    }
3796
3797    fn list_dir_recursive(
3798        dir: &std::path::Path,
3799        base: &std::path::Path,
3800        depth: u32,
3801        max_depth: u32,
3802        pattern: Option<&str>,
3803        entries: &mut Vec<serde_json::Value>,
3804    ) {
3805        if entries.len() >= MAX_DIR_ENTRIES {
3806            return;
3807        }
3808        let Ok(read_dir) = std::fs::read_dir(dir) else {
3809            return;
3810        };
3811        for entry in read_dir.flatten() {
3812            if entries.len() >= MAX_DIR_ENTRIES {
3813                return;
3814            }
3815            let path = entry.path();
3816            if path.is_symlink() {
3817                continue;
3818            }
3819            let name = entry.file_name().to_string_lossy().into_owned();
3820            let relative = path
3821                .strip_prefix(base)
3822                .unwrap_or(&path)
3823                .to_string_lossy()
3824                .into_owned();
3825
3826            if let Some(pat) = pattern
3827                && !Self::matches_glob(&name, pat)
3828                && !path.is_dir()
3829            {
3830                continue;
3831            }
3832
3833            let is_dir = path.is_dir();
3834            let meta = std::fs::metadata(&path).ok();
3835
3836            entries.push(serde_json::json!({
3837                "name": name,
3838                "path": relative,
3839                "is_dir": is_dir,
3840                "size": meta.as_ref().map(std::fs::Metadata::len),
3841                "modified": meta.as_ref()
3842                    .and_then(|m| m.modified().ok())
3843                    .map(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH)
3844                        .unwrap_or_default().as_secs()),
3845            }));
3846
3847            if is_dir && depth < max_depth {
3848                Self::list_dir_recursive(&path, base, depth + 1, max_depth, pattern, entries);
3849            }
3850        }
3851    }
3852
3853    fn matches_glob(name: &str, pattern: &str) -> bool {
3854        if pattern == "*" {
3855            return true;
3856        }
3857        if let Some(suffix) = pattern.strip_prefix("*.") {
3858            return name.ends_with(&format!(".{suffix}"));
3859        }
3860        if let Some(prefix) = pattern.strip_suffix("*") {
3861            return name.starts_with(prefix);
3862        }
3863        name == pattern
3864    }
3865
3866    /// Probe every window's JS bridge and report which are introspectable. A
3867    /// visible window that fails to respond almost always lacks the
3868    /// `victauri:default` capability — Tauri's permission ACL silently blocks
3869    /// the bridge's callback IPC, so eval/dom/animation tools see nothing. This
3870    /// turns that silent dead-end into an actionable, up-front diagnosis.
3871    async fn window_introspectability(&self) -> CallToolResult {
3872        let labels = self.bridge.list_window_labels();
3873        let states = self.bridge.get_window_states(None);
3874        let mut report = Vec::with_capacity(labels.len());
3875        let mut blind = 0usize;
3876        for label in &labels {
3877            let visible = states.iter().find(|s| &s.label == label).map(|s| s.visible);
3878            let introspectable = self.probe_bridge(Some(label)).await.is_ok();
3879            if !introspectable {
3880                blind += 1;
3881            }
3882            let note = if introspectable {
3883                "ok — Victauri JS bridge is responding".to_string()
3884            } else if visible == Some(true) {
3885                format!(
3886                    "NOT introspectable although the window is visible — almost certainly missing \
3887                     the Victauri capability. Add \"victauri:default\" to the capability file \
3888                     (src-tauri/capabilities/*.json) whose \"windows\" list includes \"{label}\", \
3889                     then rebuild. Capabilities are baked at compile time, so a rebuild is required."
3890                )
3891            } else {
3892                "NOT introspectable (window is hidden and/or has no bridge) — show the window to \
3893                 confirm, and ensure its capability includes \"victauri:default\", then rebuild."
3894                    .to_string()
3895            };
3896            report.push(serde_json::json!({
3897                "label": label,
3898                "visible": visible,
3899                "introspectable": introspectable,
3900                "note": note,
3901            }));
3902        }
3903        let hint = if blind > 0 {
3904            "Windows with introspectable:false have no working Victauri JS bridge — eval_js, \
3905             dom_snapshot, animation, find_elements, etc. cannot see them. The usual cause is a \
3906             missing \"victauri:default\" capability for that window: Tauri's per-window permission \
3907             ACL silently blocks the bridge's callback IPC. This capability is required per window, \
3908             not just for the main window. (Note: probing a blind window takes ~2s each.)"
3909        } else {
3910            "All windows are introspectable."
3911        };
3912        json_result(&serde_json::json!({
3913            "windows": report,
3914            "introspectable_count": labels.len().saturating_sub(blind),
3915            "blind_count": blind,
3916            "hint": hint,
3917        }))
3918    }
3919
3920    async fn eval_bridge(&self, code: &str, webview_label: Option<&str>) -> CallToolResult {
3921        match self.eval_with_return(code, webview_label).await {
3922            Ok(result) => CallToolResult::success(vec![Content::text(result)]),
3923            Err(e) => tool_error(e),
3924        }
3925    }
3926
3927    async fn eval_with_return(
3928        &self,
3929        code: &str,
3930        webview_label: Option<&str>,
3931    ) -> Result<String, String> {
3932        self.eval_with_return_timeout(code, webview_label, self.state.eval_timeout)
3933            .await
3934    }
3935
3936    async fn probe_bridge(&self, webview_label: Option<&str>) -> Result<(), String> {
3937        let id = uuid::Uuid::new_v4().to_string();
3938        let (tx, rx) = tokio::sync::oneshot::channel();
3939        {
3940            let mut pending = self.state.pending_evals.lock().await;
3941            pending.insert(id.clone(), tx);
3942        }
3943        let id_js = js_string(&id);
3944        let probe = format!(
3945            r#"(async()=>{{await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback',{{id:{id_js},result:'"probe_ok"'}});}})();"#
3946        );
3947        if let Err(e) = self.bridge.eval_webview(webview_label, &probe) {
3948            self.state.pending_evals.lock().await.remove(&id);
3949            return Err(format!("eval injection failed: {e}"));
3950        }
3951        if let Ok(Ok(_)) = tokio::time::timeout(std::time::Duration::from_secs(2), rx).await {
3952            Ok(())
3953        } else {
3954            self.state.pending_evals.lock().await.remove(&id);
3955            let label = webview_label.unwrap_or("default");
3956            Err(format!(
3957                "bridge not responding on window '{label}' — the window may be hidden, \
3958                 missing the victauri capability, or the JS bridge is not loaded"
3959            ))
3960        }
3961    }
3962
3963    async fn eval_with_return_timeout(
3964        &self,
3965        code: &str,
3966        webview_label: Option<&str>,
3967        timeout: std::time::Duration,
3968    ) -> Result<String, String> {
3969        // Wait for the JS bridge ready signal (sent on bridge init) before
3970        // attempting evals.  For explicitly targeted windows the probe
3971        // mechanism is still used because the ready signal only proves that
3972        // *some* webview's bridge loaded — not necessarily the targeted one.
3973        if !self
3974            .state
3975            .bridge_ready
3976            .load(std::sync::atomic::Ordering::Acquire)
3977        {
3978            let notified = self.state.bridge_notify.notified();
3979            if !self
3980                .state
3981                .bridge_ready
3982                .load(std::sync::atomic::Ordering::Acquire)
3983            {
3984                let _ = tokio::time::timeout(std::time::Duration::from_secs(5), notified).await;
3985            }
3986        }
3987
3988        // Reserved sentinel key for the default (unlabeled) window — cannot
3989        // collide with a real label.
3990        let label_key =
3991            webview_label.map_or_else(|| "\u{1}__default__".to_string(), str::to_string);
3992
3993        // Proactively probe explicitly-targeted windows once (cached), so a
3994        // hidden/unready window fails fast rather than after the full timeout.
3995        if webview_label.is_some() {
3996            let already_probed = self.probed_labels.lock().await.contains(&label_key);
3997            if !already_probed {
3998                self.probe_bridge(webview_label).await?;
3999                self.probed_labels.lock().await.insert(label_key.clone());
4000            }
4001        }
4002
4003        // Resilience: if the PREVIOUS eval on this window timed out, the bridge
4004        // may have gone away (the webview reloaded or the app crashed). Do a
4005        // fast liveness probe so this call fails in ~2s with a clear error
4006        // instead of blocking the full timeout again. If the bridge is alive
4007        // (the earlier timeout was slow code / an infinite loop), the probe
4008        // succeeds quickly and we proceed normally.
4009        if self.timed_out_labels.lock().await.remove(&label_key) {
4010            self.probe_bridge(webview_label).await.map_err(|e| {
4011                format!("{e} (previous eval on this window timed out; the webview may have reloaded or the app stopped responding)")
4012            })?;
4013        }
4014
4015        let id = uuid::Uuid::new_v4().to_string();
4016        let (tx, rx) = tokio::sync::oneshot::channel();
4017
4018        {
4019            let mut pending = self.state.pending_evals.lock().await;
4020            if pending.len() >= MAX_PENDING_EVALS {
4021                return Err(format!(
4022                    "too many concurrent eval requests (limit: {MAX_PENDING_EVALS})"
4023                ));
4024            }
4025            pending.insert(id.clone(), tx);
4026        }
4027
4028        // Auto-prepend `return` so bare expressions produce a value — but ONLY
4029        // for single expressions. Multi-statement blocks (or code containing an
4030        // explicit `return`) are used as-is. Prepending `return` to a statement
4031        // block like `foo(); return bar()` would parse as `return foo();` and
4032        // silently discard everything after the first statement (issue: core
4033        // primitive returned wrong/undefined values for "do X, then return Y").
4034        let code = if should_prepend_return(code) {
4035            format!("return {}", code.trim())
4036        } else {
4037            code.trim().to_string()
4038        };
4039
4040        let id_js = js_string(&id);
4041
4042        // Fail fast on a SYNTAX error instead of hanging for the full timeout (audit /
4043        // red-team "malformed eval consumes the full 30s"). The user code is inlined into
4044        // the script below; if it has a parse error the WHOLE script fails to parse and the
4045        // try/catch never runs, so the callback never fires. We cannot wrap the code in
4046        // `new Function`/`AsyncFunction` to surface the SyntaxError, because dynamic code
4047        // generation is gated by the same `unsafe-eval` CSP that blocks `eval()` — which is
4048        // exactly why the bridge uses an inline async-IIFE in the first place. Instead an
4049        // independent watchdog (which always parses) reports a parse error quickly: the
4050        // user-code script sets a `started` flag at its very top, so a script that fails to
4051        // parse never sets it. A valid-but-slow eval (e.g. a `wait_for` poll) sets `started`
4052        // immediately and is left to run to the real timeout — the watchdog only fires when
4053        // the code never began executing.
4054        let watchdog = format!(
4055            r"
4056            (function () {{
4057                window.__VIC_EVAL__ = window.__VIC_EVAL__ || {{}};
4058                var s = (window.__VIC_EVAL__[{id_js}] =
4059                    window.__VIC_EVAL__[{id_js}] || {{ started: false, done: false }});
4060                setTimeout(function () {{
4061                    if (s.started || s.done) return;
4062                    s.done = true;
4063                    try {{
4064                        window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4065                            id: {id_js},
4066                            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)' }})
4067                        }});
4068                    }} catch (e) {{}}
4069                    delete window.__VIC_EVAL__[{id_js}];
4070                }}, {PARSE_WATCHDOG_MS});
4071            }})();
4072            "
4073        );
4074
4075        let inject = format!(
4076            r"
4077            (async () => {{
4078                var __s = (window.__VIC_EVAL__ && window.__VIC_EVAL__[{id_js}]) || null;
4079                if (__s) __s.started = true;
4080                try {{
4081                    const __result = await (async () => {{ {code} }})();
4082                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4083                    const __type = __result === undefined ? 'undefined'
4084                        : __result === null ? 'null' : 'value';
4085                    const __val = __type === 'undefined' ? null
4086                        : __type === 'null' ? null : __result;
4087                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4088                        id: {id_js},
4089                        result: JSON.stringify({{ __victauri_ok: __val, __victauri_type: __type }})
4090                    }});
4091                }} catch (e) {{
4092                    if (__s) {{ if (__s.done) return; __s.done = true; delete window.__VIC_EVAL__[{id_js}]; }}
4093                    await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
4094                        id: {id_js},
4095                        result: JSON.stringify({{ __victauri_err: String(e && e.message || e) }})
4096                    }});
4097                }}
4098            }})();
4099            "
4100        );
4101
4102        // Inject the watchdog first so it is armed before the user code runs. Order is not
4103        // critical (the user-code script no-ops the watchdog state if it ran first), but
4104        // arming first minimises the window.
4105        if let Err(e) = self.bridge.eval_webview(webview_label, &watchdog) {
4106            self.state.pending_evals.lock().await.remove(&id);
4107            return Err(format!("eval injection failed: {e}"));
4108        }
4109        if let Err(e) = self.bridge.eval_webview(webview_label, &inject) {
4110            self.state.pending_evals.lock().await.remove(&id);
4111            return Err(format!("eval injection failed: {e}"));
4112        }
4113
4114        match tokio::time::timeout(timeout, rx).await {
4115            Ok(Ok(raw)) => {
4116                self.check_bridge_version_once();
4117                if raw.len() > MAX_EVAL_RESULT_LEN {
4118                    return Err(format!(
4119                        "eval result too large ({} bytes, limit {MAX_EVAL_RESULT_LEN})",
4120                        raw.len()
4121                    ));
4122                }
4123                unwrap_eval_envelope(raw)
4124            }
4125            Ok(Err(_)) => Err("eval callback channel closed".to_string()),
4126            Err(_) => {
4127                self.state.pending_evals.lock().await.remove(&id);
4128                // Mark this window so the NEXT eval does a fast liveness probe —
4129                // if the bridge is gone (reloaded/crashed) the next call fails in
4130                // ~2s instead of blocking the full timeout again.
4131                self.timed_out_labels.lock().await.insert(label_key.clone());
4132                Err(format!(
4133                    "eval timed out after {}s — the code began executing but never resolved. \
4134                     (A syntax/parse error would have failed fast via the parse watchdog, so \
4135                     this is NOT a parse error.) Common causes: an unresolved promise, an \
4136                     infinite loop, an `await` on something that never settles, or the webview \
4137                     reloaded / the app stopped responding mid-eval. If the app may have \
4138                     navigated or crashed, retry (the next call fails fast if the bridge is \
4139                     gone).",
4140                    timeout.as_secs()
4141                ))
4142            }
4143        }
4144    }
4145
4146    #[cfg(feature = "sqlite")]
4147    async fn run_db_health(&self, db_path: Option<&str>) -> Result<serde_json::Value, String> {
4148        // Roots: configured db_search_paths first, then app directories.
4149        let mut roots: Vec<std::path::PathBuf> = self.state.db_search_paths.clone();
4150        for d in [
4151            self.bridge.app_data_dir(),
4152            self.bridge.app_local_data_dir(),
4153            self.bridge.app_config_dir(),
4154        ]
4155        .into_iter()
4156        .flatten()
4157        {
4158            roots.push(d);
4159        }
4160
4161        let path = if let Some(p) = db_path {
4162            let candidate = std::path::Path::new(p);
4163            if candidate.is_absolute() {
4164                if !roots
4165                    .iter()
4166                    .any(|r| Self::safe_within(r, candidate).is_ok())
4167                {
4168                    return Err(format!(
4169                        "absolute path '{p}' is not within an allowed directory; \
4170                         register its parent via VictauriBuilder::db_search_paths"
4171                    ));
4172                }
4173                candidate.to_path_buf()
4174            } else {
4175                roots
4176                    .iter()
4177                    .map(|r| r.join(p))
4178                    .find(|c| c.exists())
4179                    .ok_or_else(|| format!("database not found: {p}"))?
4180            }
4181        } else {
4182            // Configured db_search_paths are EXCLUSIVE when set (don't fall back to the
4183            // OS app dirs that hold WebView internals); WebView/engine internal stores are
4184            // excluded and the largest real candidate wins (audit / red-team "wrong DB").
4185            let select_dirs: Vec<std::path::PathBuf> = if self.state.db_search_paths.is_empty() {
4186                roots.clone()
4187            } else {
4188                self.state.db_search_paths.clone()
4189            };
4190            crate::database::select_app_database(&select_dirs)?
4191        };
4192        // No further containment check needed: the path is either discovered
4193        // within an allowed root, an existing relative file joined onto an
4194        // allowed root, or an absolute path already verified above. (A
4195        // safe_within check against app_data_dir would fail when that directory
4196        // does not exist — common for apps that store data elsewhere.)
4197        let path_str = path
4198            .to_str()
4199            .ok_or_else(|| "invalid path encoding".to_string())?
4200            .to_string();
4201
4202        tokio::task::spawn_blocking(move || {
4203            let conn = rusqlite::Connection::open_with_flags(
4204                &path_str,
4205                rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4206            )
4207            .map_err(|e| format!("cannot open database: {e}"))?;
4208
4209            let journal_mode: String = conn
4210                .pragma_query_value(None, "journal_mode", |r| r.get(0))
4211                .unwrap_or_else(|_| "unknown".to_string());
4212
4213            let page_count: i64 = conn
4214                .pragma_query_value(None, "page_count", |r| r.get(0))
4215                .unwrap_or(0);
4216
4217            let page_size: i64 = conn
4218                .pragma_query_value(None, "page_size", |r| r.get(0))
4219                .unwrap_or(0);
4220
4221            let freelist_count: i64 = conn
4222                .pragma_query_value(None, "freelist_count", |r| r.get(0))
4223                .unwrap_or(0);
4224
4225            let wal_checkpoint: String = if journal_mode == "wal" {
4226                let mut info = String::from("n/a");
4227                let _ = conn.pragma_query(None, "wal_checkpoint", |r| {
4228                    let busy: i64 = r.get(0)?;
4229                    let checkpointed: i64 = r.get(1)?;
4230                    let total: i64 = r.get(2)?;
4231                    info = format!("busy={busy}, checkpointed={checkpointed}, total={total}");
4232                    Ok(())
4233                });
4234                info
4235            } else {
4236                "n/a (not WAL mode)".to_string()
4237            };
4238
4239            let integrity: String = conn
4240                .pragma_query_value(None, "quick_check", |r| r.get(0))
4241                .unwrap_or_else(|_| "failed".to_string());
4242
4243            let db_size_bytes = page_count * page_size;
4244            let db_size_mb = db_size_bytes as f64 / (1024.0 * 1024.0);
4245
4246            let mut tables = Vec::new();
4247            if let Ok(mut stmt) =
4248                conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
4249                && let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(0))
4250            {
4251                for name in rows.flatten() {
4252                    let count: i64 = conn
4253                        .query_row(&format!("SELECT count(*) FROM [{name}]"), [], |r| r.get(0))
4254                        .unwrap_or(0);
4255                    tables.push(serde_json::json!({
4256                        "name": name,
4257                        "row_count": count,
4258                    }));
4259                }
4260            }
4261
4262            Ok(serde_json::json!({
4263                "database": path_str,
4264                "journal_mode": journal_mode,
4265                "page_count": page_count,
4266                "page_size": page_size,
4267                "db_size_mb": (db_size_mb * 100.0).round() / 100.0,
4268                "freelist_count": freelist_count,
4269                "wal_checkpoint": wal_checkpoint,
4270                "integrity_check": integrity,
4271                "tables": tables,
4272            }))
4273        })
4274        .await
4275        .map_err(|e| format!("db health task failed: {e}"))?
4276    }
4277
4278    fn check_bridge_version_once(&self) {
4279        if self.bridge_checked.swap(true, Ordering::Relaxed) {
4280            return;
4281        }
4282        let handler = self.clone();
4283        tokio::spawn(async move {
4284            match handler
4285                .eval_with_return_timeout(
4286                    "window.__VICTAURI__?.version",
4287                    None,
4288                    std::time::Duration::from_secs(5),
4289                )
4290                .await
4291            {
4292                Ok(v) => {
4293                    let v = v.trim_matches('"');
4294                    if v == BRIDGE_VERSION {
4295                        tracing::debug!("Bridge version verified: {v}");
4296                    } else {
4297                        tracing::warn!(
4298                            "Bridge version mismatch: Rust expects {BRIDGE_VERSION}, JS reports {v}"
4299                        );
4300                    }
4301                }
4302                Err(e) => tracing::debug!("Bridge version check skipped: {e}"),
4303            }
4304        });
4305    }
4306}
4307
4308const SERVER_INSTRUCTIONS: &str = "Victauri is a FULL-STACK inspection AND INTERVENTION tool for Tauri applications. \
4309It provides simultaneous access to three layers: (1) the WEBVIEW (DOM, interactions, JS eval), \
4310(2) the IPC LAYER (command registry, invoke commands, intercept traffic), and \
4311(3) the RUST BACKEND (app config, file system, SQLite databases, process memory). \
4312\n\nBACKEND tools (direct Rust access, no webview needed): \
4313'app_info' (app config, directory paths, discovered databases, process info), \
4314'list_app_dir' (browse app data/config/log directories), \
4315'read_app_file' (read files from app directories), \
4316'query_db' (read-only SQLite queries with auto-discovery). \
4317\n\nBACKEND INTROSPECTION (CDP cannot do this — Victauri-exclusive): \
4318'introspect' (command_timings, coverage, contract_record/check/list/clear, startup_timing, \
4319capabilities, db_health, plugin_state, processes, plugin_tasks, event_bus, event_bus_clear) — \
4320Rust-side performance profiling, IPC contract testing, command coverage analysis, startup timing, \
4321capability/security auditing, database diagnostics, plugin state, child process enumeration, \
4322task tracking, and automatic Tauri event bus monitoring. \
4323'fault' (inject, list, clear, clear_all) — chaos engineering: inject delays, errors, \
4324drops, and response corruption into Tauri commands at the Rust layer. \
4325'explain' (summary, last_action, diff) — cross-layer activity correlation: summarizes recent \
4326activity across IPC + DOM + console + network + window events into a coherent narrative. \
4327\n\nWEBVIEW tools: \
4328'interact' (click, hover, focus, scroll, select), 'input' (fill, type_text, press_key), \
4329'inspect' (get_styles, get_bounding_boxes, highlight, audit_accessibility, get_performance), \
4330'css' (inject, remove), eval_js, dom_snapshot, find_elements, screenshot. \
4331\n\nIPC tools: invoke_command, get_registry, detect_ghost_commands, check_ipc_integrity. \
4332\n\nCOMPOUND tools with an 'action' parameter: \
4333'window' (get_state, list, manage, resize, move_to, set_title), \
4334'storage' (get, set, delete, get_cookies), 'navigate' (go_to, go_back, get_history, \
4335set_dialog_response, get_dialog_log), 'recording' (start, stop, checkpoint, list_checkpoints, \
4336get_events, events_between, get_replay, export, import, replay), \
4337'logs' (console, network, ipc, navigation, dialogs, events, slow_ipc). \
4338\n\nOTHER: verify_state, wait_for (incl. 'expression'/'event' conditions to await \
4339async backend work to true completion), assert_semantic, resolve_command, \
4340app_state (app-defined backend state probes), \
4341get_memory_stats, get_plugin_info, get_diagnostics.";
4342
4343impl ServerHandler for VictauriMcpHandler {
4344    fn get_info(&self) -> ServerInfo {
4345        ServerInfo::new(
4346            ServerCapabilities::builder()
4347                .enable_tools()
4348                .enable_resources()
4349                .enable_resources_subscribe()
4350                .build(),
4351        )
4352        .with_instructions(SERVER_INSTRUCTIONS)
4353    }
4354
4355    async fn list_tools(
4356        &self,
4357        _request: Option<PaginatedRequestParams>,
4358        _context: RequestContext<RoleServer>,
4359    ) -> Result<ListToolsResult, ErrorData> {
4360        let all_tools = Self::tool_router().list_all();
4361        let filtered: Vec<Tool> = all_tools
4362            .into_iter()
4363            .filter(|t| self.state.privacy.is_tool_enabled(t.name.as_ref()))
4364            .collect();
4365        Ok(ListToolsResult {
4366            tools: filtered,
4367            ..Default::default()
4368        })
4369    }
4370
4371    async fn call_tool(
4372        &self,
4373        request: CallToolRequestParams,
4374        context: RequestContext<RoleServer>,
4375    ) -> Result<CallToolResult, ErrorData> {
4376        let tool_name: String = request.name.as_ref().to_owned();
4377        // Centralized authorization: gate on the canonical `tool.action` capability
4378        // resolved from the call arguments, matching the REST path in `execute_tool`.
4379        let args_value = serde_json::Value::Object(request.arguments.clone().unwrap_or_default());
4380        let capability = authz::canonical_capability(&tool_name, &args_value);
4381        if !self.state.privacy.is_call_allowed(&tool_name, &capability) {
4382            tracing::debug!(tool = %tool_name, capability = %capability, "tool call blocked by privacy config");
4383            return Ok(tool_disabled(&capability));
4384        }
4385        self.state
4386            .tool_invocations
4387            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4388        let start = std::time::Instant::now();
4389        tracing::debug!(tool = %tool_name, "tool invocation started");
4390        let ctx = ToolCallContext::new(self, request, context);
4391        let result = Self::tool_router().call(ctx).await;
4392        let elapsed = start.elapsed();
4393        tracing::debug!(
4394            tool = %tool_name,
4395            elapsed_ms = elapsed.as_millis() as u64,
4396            is_error = result.as_ref().map_or(true, |r| r.is_error.unwrap_or(false)),
4397            "tool invocation completed"
4398        );
4399
4400        // Centralized output redaction: apply to all text content so no
4401        // individual tool can accidentally leak secrets.
4402        if self.state.privacy.redaction_enabled {
4403            result.map(|mut r| {
4404                for item in &mut r.content {
4405                    if let RawContent::Text(ref mut tc) = item.raw {
4406                        tc.text = self.state.privacy.redact_output(&tc.text);
4407                    }
4408                }
4409                r
4410            })
4411        } else {
4412            result
4413        }
4414    }
4415
4416    fn get_tool(&self, name: &str) -> Option<Tool> {
4417        if !self.state.privacy.is_tool_enabled(name) {
4418            return None;
4419        }
4420        Self::tool_router().get(name).cloned()
4421    }
4422
4423    async fn list_resources(
4424        &self,
4425        _request: Option<PaginatedRequestParams>,
4426        _context: RequestContext<RoleServer>,
4427    ) -> Result<ListResourcesResult, ErrorData> {
4428        Ok(ListResourcesResult {
4429            resources: vec![
4430                RawResource::new(RESOURCE_URI_IPC_LOG, "ipc-log")
4431                    .with_description(
4432                        "Live IPC call log — all commands invoked between frontend and backend",
4433                    )
4434                    .with_mime_type("application/json")
4435                    .no_annotation(),
4436                RawResource::new(RESOURCE_URI_WINDOWS, "windows")
4437                    .with_description(
4438                        "Current state of all Tauri windows — position, size, visibility, focus",
4439                    )
4440                    .with_mime_type("application/json")
4441                    .no_annotation(),
4442                RawResource::new(RESOURCE_URI_STATE, "state")
4443                    .with_description(
4444                        "Victauri plugin state — event count, registered commands, memory stats",
4445                    )
4446                    .with_mime_type("application/json")
4447                    .no_annotation(),
4448            ],
4449            ..Default::default()
4450        })
4451    }
4452
4453    async fn read_resource(
4454        &self,
4455        request: ReadResourceRequestParams,
4456        _context: RequestContext<RoleServer>,
4457    ) -> Result<ReadResourceResult, ErrorData> {
4458        let uri = &request.uri;
4459        // Resources bypass the tool dispatcher, so they must apply the same privacy
4460        // gate themselves (audit B1): a strict profile that blocks log/window reads
4461        // as tools must not be able to read the same data via a resource.
4462        if let Some(cap) = resource_required_capability(uri.as_str())
4463            && !self.state.privacy.is_tool_enabled(cap)
4464        {
4465            return Err(ErrorData::invalid_request(
4466                format!("resource {uri} is not permitted by the current privacy configuration"),
4467                None,
4468            ));
4469        }
4470        let json = match uri.as_str() {
4471            RESOURCE_URI_IPC_LOG => {
4472                if let Ok(json) = self
4473                    .eval_with_return("return window.__VICTAURI__?.getIpcLog()", None)
4474                    .await
4475                {
4476                    json
4477                } else {
4478                    let calls = self.state.event_log.ipc_calls();
4479                    serde_json::to_string_pretty(&calls)
4480                        .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4481                }
4482            }
4483            RESOURCE_URI_WINDOWS => {
4484                let states = self.bridge.get_window_states(None);
4485                serde_json::to_string_pretty(&states)
4486                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4487            }
4488            RESOURCE_URI_STATE => {
4489                let state_json = serde_json::json!({
4490                    "events_captured": self.state.event_log.len(),
4491                    "commands_registered": self.state.registry.count(),
4492                    "memory": crate::memory::current_stats(),
4493                    "port": self.state.port.load(Ordering::Relaxed),
4494                });
4495                serde_json::to_string_pretty(&state_json)
4496                    .map_err(|e| ErrorData::internal_error(e.to_string(), None))?
4497            }
4498            _ => {
4499                return Err(ErrorData::resource_not_found(
4500                    format!("unknown resource: {uri}"),
4501                    None,
4502                ));
4503            }
4504        };
4505
4506        let json = if self.state.privacy.redaction_enabled {
4507            self.state.privacy.redact_output(&json)
4508        } else {
4509            json
4510        };
4511
4512        Ok(ReadResourceResult::new(vec![ResourceContents::text(
4513            json, uri,
4514        )]))
4515    }
4516
4517    async fn subscribe(
4518        &self,
4519        request: SubscribeRequestParams,
4520        _context: RequestContext<RoleServer>,
4521    ) -> Result<(), ErrorData> {
4522        let uri = &request.uri;
4523        // Same privacy gate as read_resource (audit B1) — don't let a blocked
4524        // resource be subscribed to for push updates.
4525        if let Some(cap) = resource_required_capability(uri.as_str())
4526            && !self.state.privacy.is_tool_enabled(cap)
4527        {
4528            return Err(ErrorData::invalid_request(
4529                format!("resource {uri} is not permitted by the current privacy configuration"),
4530                None,
4531            ));
4532        }
4533        match uri.as_str() {
4534            RESOURCE_URI_IPC_LOG | RESOURCE_URI_WINDOWS | RESOURCE_URI_STATE => {
4535                self.subscriptions.lock().await.insert(uri.clone());
4536                tracing::info!("Client subscribed to resource: {uri}");
4537                Ok(())
4538            }
4539            _ => Err(ErrorData::resource_not_found(
4540                format!("unknown resource: {uri}"),
4541                None,
4542            )),
4543        }
4544    }
4545
4546    async fn unsubscribe(
4547        &self,
4548        request: UnsubscribeRequestParams,
4549        _context: RequestContext<RoleServer>,
4550    ) -> Result<(), ErrorData> {
4551        self.subscriptions.lock().await.remove(&request.uri);
4552        tracing::info!("Client unsubscribed from resource: {}", request.uri);
4553        Ok(())
4554    }
4555}
4556
4557/// Build a JS expression that takes an array of log entries (`source_expr`),
4558/// keeps at most `limit` of the most recent, and truncates any per-entry field
4559/// larger than [`MAX_LOG_FIELD_BYTES`]. This keeps IPC/network log results under
4560/// the eval size cap on busy apps where individual entries carry large bodies.
4561///
4562/// The returned code is a complete `return (...)` statement.
4563fn trimmed_log_js(source_expr: &str, limit: usize) -> String {
4564    let mb = MAX_LOG_FIELD_BYTES;
4565    format!(
4566        r"return (function() {{
4567            var MB = {mb};
4568            function trimField(v) {{
4569                if (typeof v === 'string') {{
4570                    return v.length > MB ? (v.slice(0, MB) + '…[+' + (v.length - MB) + ' bytes truncated]') : v;
4571                }}
4572                if (v && typeof v === 'object') {{
4573                    var s; try {{ s = JSON.stringify(v); }} catch (e) {{ s = ''; }}
4574                    if (s.length > MB) {{ return '[truncated ' + s.length + ' bytes]'; }}
4575                }}
4576                return v;
4577            }}
4578            function trimEntry(e) {{
4579                if (e == null || typeof e !== 'object') return e;
4580                var out = Array.isArray(e) ? [] : {{}};
4581                for (var k in e) {{ if (Object.prototype.hasOwnProperty.call(e, k)) out[k] = trimField(e[k]); }}
4582                return out;
4583            }}
4584            var arr = {source_expr} || [];
4585            if (arr.length > {limit}) arr = arr.slice(-{limit});
4586            return arr.map(trimEntry);
4587        }})()"
4588    )
4589}
4590
4591/// Unwrap the `{"__victauri_ok": <val>, "__victauri_type": <t>}` (or
4592/// `{"__victauri_err": <msg>}`) envelope produced by the eval bridge into the
4593/// value/error string returned to callers.
4594///
4595/// Parsing uses `serde_json`'s default recursion limit (it is intentionally NOT
4596/// disabled — an unbounded recursive parse of a pathologically deep result
4597/// overflows the worker thread stack and crashes the host). When the parse
4598/// fails because the value is too deeply nested, the envelope is stripped by
4599/// string slicing (no recursion) so the actual value is still returned rather
4600/// than leaking the raw envelope string.
4601fn unwrap_eval_envelope(raw: String) -> Result<String, String> {
4602    if let Ok(envelope) = serde_json::from_str::<serde_json::Value>(&raw) {
4603        if let Some(err) = envelope.get("__victauri_err") {
4604            return Err(format!(
4605                "JavaScript error: {}",
4606                err.as_str().unwrap_or("unknown error")
4607            ));
4608        }
4609        if envelope.get("__victauri_ok").is_some() {
4610            let js_type = envelope
4611                .get("__victauri_type")
4612                .and_then(|t| t.as_str())
4613                .unwrap_or("value");
4614            return match js_type {
4615                "undefined" => Ok("undefined".to_string()),
4616                "null" => Ok("null".to_string()),
4617                _ => Ok(serde_json::to_string(&envelope["__victauri_ok"])
4618                    .unwrap_or_else(|_| "null".to_string())),
4619            };
4620        }
4621    }
4622    // Fallback for results too deeply nested for the recursion-limited parser.
4623    if let Some(after) = raw.strip_prefix(r#"{"__victauri_ok":"#)
4624        && let Some(idx) = after.rfind(r#","__victauri_type":"#)
4625    {
4626        return Ok(after[..idx].to_string());
4627    }
4628    if let Some(after) = raw.strip_prefix(r#"{"__victauri_err":"#) {
4629        let msg = after.trim_end_matches('}').trim_matches('"');
4630        return Err(format!("JavaScript error: {msg}"));
4631    }
4632    Ok(raw)
4633}
4634
4635/// Statement keywords where a leading `return` would be a syntax error.
4636const STMT_STARTS: &[&str] = &[
4637    "return ",
4638    "return;",
4639    "return\n",
4640    "return\t",
4641    "if ",
4642    "if(",
4643    "for ",
4644    "for(",
4645    "while ",
4646    "while(",
4647    "switch ",
4648    "switch(",
4649    "try ",
4650    "try{",
4651    "const ",
4652    "let ",
4653    "var ",
4654    "function ",
4655    "function(",
4656    "function*",
4657    "class ",
4658    "throw ",
4659    "do ",
4660    "do{",
4661    "{",
4662    "async function",
4663    "debugger",
4664];
4665
4666/// String/template/comment scan state for [`should_prepend_return`].
4667#[derive(PartialEq, Clone, Copy)]
4668enum ScanState {
4669    Code,
4670    SingleQuote,
4671    DoubleQuote,
4672    Template,
4673}
4674
4675/// Decide whether to wrap `code` with a leading `return`.
4676///
4677/// Only a single bare expression should get `return` prepended. Code that is a
4678/// multi-statement block, contains an explicit top-level `return`, or starts
4679/// with a statement keyword is used as-is — prepending `return` to such code
4680/// would execute only the first statement and silently discard the rest.
4681///
4682/// The scan is string/template/comment-aware and only treats a `;` or an
4683/// explicit `return` token as significant when it occurs at bracket depth 0
4684/// outside of any string, template literal, or comment.
4685fn should_prepend_return(code: &str) -> bool {
4686    use ScanState::{Code, DoubleQuote, SingleQuote, Template};
4687
4688    let code = code.trim();
4689    if code.is_empty() {
4690        return false;
4691    }
4692
4693    if STMT_STARTS.iter().any(|k| code.starts_with(k)) {
4694        return false;
4695    }
4696
4697    let bytes = code.as_bytes();
4698    let mut i = 0;
4699    let mut depth: i32 = 0;
4700    let mut state = ScanState::Code;
4701
4702    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$';
4703    // Is there a top-level `return` token starting at byte `i` (word-bounded)?
4704    let is_return_token = |i: usize| -> bool {
4705        let prev_ok = i == 0 || !is_ident(bytes[i - 1]);
4706        prev_ok
4707            && code[i..].starts_with("return")
4708            && bytes.get(i + 6).copied().is_none_or(|b| !is_ident(b))
4709    };
4710
4711    while i < bytes.len() {
4712        let c = bytes[i];
4713        match state {
4714            Code => match c {
4715                b'\'' => state = SingleQuote,
4716                b'"' => state = DoubleQuote,
4717                b'`' => state = Template,
4718                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
4719                    while i < bytes.len() && bytes[i] != b'\n' {
4720                        i += 1;
4721                    }
4722                    continue;
4723                }
4724                b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4725                    i += 2;
4726                    while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
4727                        i += 1;
4728                    }
4729                    i += 2;
4730                    continue;
4731                }
4732                b'(' | b'[' | b'{' => depth += 1,
4733                b')' | b']' | b'}' => depth -= 1,
4734                // A top-level `;` with more code after it == multi-statement.
4735                b';' if depth <= 0 && !code[i + 1..].trim().is_empty() => return false,
4736                // An explicit top-level `return` token means the code already returns.
4737                b'r' if depth <= 0 && is_return_token(i) => return false,
4738                _ => {}
4739            },
4740            SingleQuote => {
4741                if c == b'\\' {
4742                    i += 1;
4743                } else if c == b'\'' {
4744                    state = Code;
4745                }
4746            }
4747            DoubleQuote => {
4748                if c == b'\\' {
4749                    i += 1;
4750                } else if c == b'"' {
4751                    state = Code;
4752                }
4753            }
4754            Template => {
4755                if c == b'\\' {
4756                    i += 1;
4757                } else if c == b'`' {
4758                    state = Code;
4759                }
4760            }
4761        }
4762        i += 1;
4763    }
4764
4765    true
4766}
4767
4768#[cfg(test)]
4769mod prop_tests {
4770    //! Property-based tests for the eval auto-return heuristic — the code that
4771    //! caused the worst bug in the system (silent corruption of multi-statement
4772    //! eval) and has bitten twice. These generate many JS-ish snippets and
4773    //! assert the invariants that keep eval correct.
4774    use super::should_prepend_return;
4775    use proptest::prelude::*;
4776
4777    /// A small set of non-keyword identifier-ish expressions.
4778    fn ident() -> impl Strategy<Value = String> {
4779        prop_oneof![
4780            Just("a".to_string()),
4781            Just("x".to_string()),
4782            Just("foo".to_string()),
4783            Just("window.x".to_string()),
4784            Just("document.title".to_string()),
4785            Just("obj.prop".to_string()),
4786            Just("arr[0]".to_string()),
4787            Just("localStorage".to_string()),
4788        ]
4789    }
4790
4791    /// A single bare expression: never starts with a statement keyword, has no
4792    /// top-level `;`, and contains no `return`.
4793    fn bare_expr() -> impl Strategy<Value = String> {
4794        prop_oneof![
4795            ident(),
4796            (ident(), ident()).prop_map(|(a, b)| format!("{a} + {b}")),
4797            (ident(), ident()).prop_map(|(a, b)| format!("{a}({b})")),
4798            ident().prop_map(|a| format!("{a}.length")),
4799            any::<u16>().prop_map(|n| n.to_string()),
4800        ]
4801    }
4802
4803    proptest! {
4804        /// Must never panic or hang on ANY input — including malformed code,
4805        /// unbalanced quotes, and arbitrary unicode (the scanner indexes bytes).
4806        #[test]
4807        fn never_panics_on_arbitrary_input(s in ".{0,256}") {
4808            let _ = should_prepend_return(&s);
4809        }
4810
4811        /// A single bare expression is safe to wrap with `return` → true.
4812        #[test]
4813        fn bare_expressions_are_prepended(e in bare_expr()) {
4814            prop_assert!(should_prepend_return(&e), "bare expr not prepended: {e:?}");
4815        }
4816
4817        /// THE critical bug class: `<expr>; return <expr>` must NOT be prepended
4818        /// (else `return <expr>;` runs and the rest is silently discarded).
4819        #[test]
4820        fn semicolon_multistatement_with_return_never_prepended(
4821            setup in bare_expr(), ret in bare_expr()
4822        ) {
4823            let code = format!("{setup}; return {ret}");
4824            prop_assert!(!should_prepend_return(&code), "would corrupt: {code:?}");
4825        }
4826
4827        /// Newline-separated (ASI) explicit return must also be left as-is.
4828        #[test]
4829        fn newline_explicit_return_never_prepended(pre in bare_expr(), ret in bare_expr()) {
4830            let code = format!("{pre}\nreturn {ret}");
4831            prop_assert!(!should_prepend_return(&code), "explicit return prepended: {code:?}");
4832        }
4833
4834        /// `;` or the word `return` INSIDE a string literal must not trigger a
4835        /// false multi-statement split — a bare string is one expression.
4836        #[test]
4837        fn semicolons_and_return_inside_strings_are_ignored(inner in "[a-z0-9;= ]{0,24}") {
4838            // `inner` never contains a quote, so the literal is well-formed.
4839            let code = format!("'do;not;split return {inner}'");
4840            prop_assert!(should_prepend_return(&code), "string literal mis-split: {code:?}");
4841        }
4842    }
4843}
4844
4845#[cfg(test)]
4846mod tests {
4847    use super::*;
4848
4849    #[test]
4850    fn env_filter_drops_secrets_keeps_safe() {
4851        // Safe, non-secret vars pass.
4852        assert!(is_safe_env_key("HOME"));
4853        assert!(is_safe_env_key("LANG"));
4854        assert!(is_safe_env_key("TAURI_ENV_PLATFORM"));
4855        assert!(is_safe_env_key("VICTAURI_PORT"));
4856        // Secret-looking vars are dropped even under a safe prefix (audit #5).
4857        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY"));
4858        assert!(!is_safe_env_key("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"));
4859        assert!(!is_safe_env_key("VICTAURI_AUTH_TOKEN"));
4860        assert!(!is_safe_env_key("VICTAURI_API_KEY"));
4861        // Unknown prefixes are dropped regardless.
4862        assert!(!is_safe_env_key("AWS_SECRET_ACCESS_KEY"));
4863        assert!(!is_safe_env_key("RANDOM_VAR"));
4864        // The broad TAURI_ namespace is no longer allowed — only TAURI_ENV_ — so
4865        // app-custom TAURI_ secrets are dropped even without a denylist hit.
4866        assert!(!is_safe_env_key("TAURI_CUSTOM_THING"));
4867        // Adversarial leaks closed (audit #5 follow-up): connection strings,
4868        // passphrases, PATs, JWTs, etc. under an allowed prefix.
4869        assert!(!is_safe_env_key("VICTAURI_DB_DSN"));
4870        assert!(!is_safe_env_key("VICTAURI_SIGNING_PASSPHRASE"));
4871        assert!(!is_safe_env_key("VICTAURI_GH_PAT"));
4872        assert!(!is_safe_env_key("VICTAURI_JWT"));
4873        assert!(!is_safe_env_key("VICTAURI_SESSION_ID"));
4874    }
4875
4876    #[test]
4877    fn prepend_return_bare_expressions() {
4878        assert!(should_prepend_return("document.title"));
4879        assert!(should_prepend_return("5 + 5"));
4880        assert!(should_prepend_return("\"justexpr\""));
4881        assert!(should_prepend_return("await fetch('/x')"));
4882        assert!(should_prepend_return(
4883            "document.querySelectorAll('a').length"
4884        ));
4885        assert!(should_prepend_return("x ? a : b"));
4886        // Single trailing semicolon on a bare expression is still an expression.
4887        assert!(should_prepend_return("document.title;"));
4888        // Semicolons inside strings must not be treated as boundaries.
4889        assert!(should_prepend_return("'a;b;c'"));
4890        assert!(should_prepend_return("\"x;y\".length"));
4891        // IIFE workaround: the `;` lives inside the arrow body (depth > 0).
4892        assert!(should_prepend_return("(()=>{window.x=5; return 'ok'})()"));
4893    }
4894
4895    #[test]
4896    fn no_prepend_for_statement_blocks() {
4897        // The original silent-corruption cases.
4898        assert!(!should_prepend_return(
4899            "localStorage.setItem('k','v'); return localStorage.getItem('k')"
4900        ));
4901        assert!(!should_prepend_return(
4902            "window.scrollTo(0,50); return window.scrollY"
4903        ));
4904        assert!(!should_prepend_return("console.log('x'); return 123"));
4905        assert!(!should_prepend_return("window.__z=7; return 'ok'"));
4906        // Explicit return without a preceding semicolon (newline-separated).
4907        assert!(!should_prepend_return("window.x = 5\nreturn window.x"));
4908    }
4909
4910    #[test]
4911    fn no_prepend_for_statement_keywords() {
4912        assert!(!should_prepend_return("return 42"));
4913        assert!(!should_prepend_return("const x = 1; return x"));
4914        assert!(!should_prepend_return("let y = 2"));
4915        assert!(!should_prepend_return("var z = 3"));
4916        assert!(!should_prepend_return("if (x) { return 1 }"));
4917        assert!(!should_prepend_return("for (const x of y) doThing(x)"));
4918        assert!(!should_prepend_return("throw new Error('x')"));
4919        assert!(!should_prepend_return("function f(){}"));
4920        assert!(!should_prepend_return("{ a: 1 }")); // object-literal-as-block ambiguity → as-is
4921    }
4922
4923    #[test]
4924    fn empty_code_no_prepend() {
4925        assert!(!should_prepend_return(""));
4926        assert!(!should_prepend_return("   "));
4927    }
4928
4929    #[test]
4930    fn envelope_unwrap_value() {
4931        assert_eq!(
4932            unwrap_eval_envelope(r#"{"__victauri_ok":"4DA","__victauri_type":"value"}"#.into()),
4933            Ok("\"4DA\"".to_string())
4934        );
4935        assert_eq!(
4936            unwrap_eval_envelope(r#"{"__victauri_ok":42,"__victauri_type":"value"}"#.into()),
4937            Ok("42".to_string())
4938        );
4939    }
4940
4941    #[test]
4942    fn envelope_unwrap_undefined_null() {
4943        assert_eq!(
4944            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"undefined"}"#.into()),
4945            Ok("undefined".to_string())
4946        );
4947        assert_eq!(
4948            unwrap_eval_envelope(r#"{"__victauri_ok":null,"__victauri_type":"null"}"#.into()),
4949            Ok("null".to_string())
4950        );
4951    }
4952
4953    #[test]
4954    fn envelope_unwrap_error() {
4955        let r = unwrap_eval_envelope(r#"{"__victauri_err":"boom"}"#.into());
4956        assert!(r.unwrap_err().contains("boom"));
4957    }
4958
4959    #[test]
4960    fn envelope_unwrap_deeply_nested_does_not_leak() {
4961        // Build an envelope whose value is nested far deeper than serde_json's
4962        // default recursion limit (128). The full parse fails, so the slice
4963        // fallback must return the value — NOT the raw `__victauri_ok` envelope.
4964        let mut value = String::from("0");
4965        for _ in 0..300 {
4966            value = format!("{{\"n\":{value}}}");
4967        }
4968        let raw = format!(r#"{{"__victauri_ok":{value},"__victauri_type":"value"}}"#);
4969        let out = unwrap_eval_envelope(raw).unwrap();
4970        assert!(
4971            out.starts_with(r#"{"n":"#),
4972            "deep value should be unwrapped, got: {}",
4973            &out[..out.len().min(40)]
4974        );
4975        assert!(
4976            !out.contains("__victauri_ok"),
4977            "envelope must not leak into the result"
4978        );
4979    }
4980
4981    #[test]
4982    fn js_string_simple() {
4983        assert_eq!(js_string("hello"), "\"hello\"");
4984    }
4985
4986    #[test]
4987    fn js_string_single_quotes() {
4988        let result = js_string("it's a test");
4989        assert!(result.contains("it's a test"));
4990    }
4991
4992    #[test]
4993    fn js_string_double_quotes() {
4994        let result = js_string(r#"say "hello""#);
4995        assert!(result.contains(r#"\""#));
4996    }
4997
4998    #[test]
4999    fn js_string_backslashes() {
5000        let result = js_string(r"path\to\file");
5001        assert!(result.contains(r"\\"));
5002    }
5003
5004    #[test]
5005    fn js_string_newlines_and_tabs() {
5006        let result = js_string("line1\nline2\ttab");
5007        assert!(result.contains(r"\n"));
5008        assert!(result.contains(r"\t"));
5009        assert!(!result.contains('\n'));
5010    }
5011
5012    #[test]
5013    fn js_string_null_bytes() {
5014        let input = String::from_utf8(b"before\x00after".to_vec()).unwrap();
5015        let result = js_string(&input);
5016        // serde_json escapes null bytes as
5017        assert!(result.contains("\\u0000"));
5018        assert!(!result.contains('\0'));
5019    }
5020
5021    #[test]
5022    fn js_string_template_literal_injection() {
5023        let result = js_string("`${alert(1)}`");
5024        // Should not contain unescaped backticks that could break template literals
5025        // serde_json wraps in double quotes, so backticks are safe
5026        assert!(result.starts_with('"'));
5027        assert!(result.ends_with('"'));
5028    }
5029
5030    #[test]
5031    fn js_string_unicode_separators() {
5032        // U+2028 (Line Separator) and U+2029 (Paragraph Separator) are valid in
5033        // JSON strings per RFC 8259, and serde_json passes them through literally.
5034        // Since js_string is used inside JS double-quoted strings (not template
5035        // literals), they are safe in modern JS engines (ES2019+).
5036        let result = js_string("a\u{2028}b\u{2029}c");
5037        // Verify the string is valid JSON that round-trips correctly
5038        let decoded: String = serde_json::from_str(&result).unwrap();
5039        assert_eq!(decoded, "a\u{2028}b\u{2029}c");
5040    }
5041
5042    #[test]
5043    fn js_string_empty() {
5044        assert_eq!(js_string(""), "\"\"");
5045    }
5046
5047    #[test]
5048    fn js_string_html_script_close() {
5049        // </script> in a JS string inside HTML could break out of script tags
5050        let result = js_string("</script><img onerror=alert(1)>");
5051        assert!(result.starts_with('"'));
5052        // The string is JSON-encoded; verify it round-trips safely
5053        let decoded: String = serde_json::from_str(&result).unwrap();
5054        assert_eq!(decoded, "</script><img onerror=alert(1)>");
5055    }
5056
5057    #[test]
5058    fn js_string_very_long() {
5059        let long = "a".repeat(100_000);
5060        let result = js_string(&long);
5061        assert!(result.len() >= 100_002); // quotes + content
5062    }
5063
5064    // ── URL validation tests ────────────────────────────────────────────────
5065
5066    #[test]
5067    fn url_allows_http() {
5068        assert!(validate_url("http://example.com", false).is_ok());
5069    }
5070
5071    #[test]
5072    fn url_allows_https() {
5073        assert!(validate_url("https://example.com/path?q=1", false).is_ok());
5074    }
5075
5076    #[test]
5077    fn url_allows_http_localhost() {
5078        assert!(validate_url("http://localhost:3000", false).is_ok());
5079    }
5080
5081    #[test]
5082    fn url_blocks_file_by_default() {
5083        let err = validate_url("file:///etc/passwd", false).unwrap_err();
5084        assert!(err.contains("file"), "error should mention the file scheme");
5085    }
5086
5087    #[test]
5088    fn url_allows_file_when_opted_in() {
5089        assert!(validate_url("file:///tmp/test.html", true).is_ok());
5090    }
5091
5092    #[test]
5093    fn url_blocks_javascript() {
5094        assert!(validate_url("javascript:alert(1)", false).is_err());
5095    }
5096
5097    #[test]
5098    fn url_blocks_javascript_case_insensitive() {
5099        assert!(validate_url("JAVASCRIPT:alert(1)", false).is_err());
5100    }
5101
5102    #[test]
5103    fn url_blocks_data_scheme() {
5104        assert!(validate_url("data:text/html,<script>alert(1)</script>", false).is_err());
5105    }
5106
5107    #[test]
5108    fn url_blocks_vbscript() {
5109        assert!(validate_url("vbscript:MsgBox(1)", false).is_err());
5110    }
5111
5112    #[test]
5113    fn url_rejects_invalid() {
5114        assert!(validate_url("not a url at all", false).is_err());
5115    }
5116
5117    #[test]
5118    fn url_strips_control_chars() {
5119        // Control characters should be stripped, leaving a valid URL
5120        let input = format!("http://example{}com", '\0');
5121        assert!(validate_url(&input, false).is_ok());
5122    }
5123
5124    // ── CSS color sanitization tests ───────────────────────────────────────
5125
5126    #[test]
5127    fn css_color_valid_hex() {
5128        assert_eq!(sanitize_css_color("#ff0000").unwrap(), "#ff0000");
5129        assert_eq!(sanitize_css_color("#FFF").unwrap(), "#FFF");
5130        assert_eq!(sanitize_css_color("#12345678").unwrap(), "#12345678");
5131    }
5132
5133    #[test]
5134    fn css_color_valid_rgb() {
5135        assert_eq!(
5136            sanitize_css_color("rgb(255, 0, 0)").unwrap(),
5137            "rgb(255, 0, 0)"
5138        );
5139        assert_eq!(
5140            sanitize_css_color("rgba(0, 0, 0, 0.5)").unwrap(),
5141            "rgba(0, 0, 0, 0.5)"
5142        );
5143    }
5144
5145    #[test]
5146    fn css_color_valid_named() {
5147        assert_eq!(sanitize_css_color("red").unwrap(), "red");
5148        assert_eq!(sanitize_css_color("transparent").unwrap(), "transparent");
5149    }
5150
5151    #[test]
5152    fn css_color_valid_hsl() {
5153        assert_eq!(
5154            sanitize_css_color("hsl(120, 50%, 50%)").unwrap(),
5155            "hsl(120, 50%, 50%)"
5156        );
5157    }
5158
5159    #[test]
5160    fn css_color_rejects_too_long() {
5161        let long = "a".repeat(101);
5162        assert!(sanitize_css_color(&long).is_err());
5163    }
5164
5165    #[test]
5166    fn css_color_rejects_backslash_escapes() {
5167        assert!(sanitize_css_color(r"red\00").is_err());
5168        assert!(sanitize_css_color(r"\72\65\64").is_err());
5169    }
5170
5171    #[test]
5172    fn css_color_rejects_url_injection() {
5173        assert!(sanitize_css_color("url(http://evil.com)").is_err());
5174        assert!(sanitize_css_color("URL(http://evil.com)").is_err());
5175    }
5176
5177    #[test]
5178    fn css_color_rejects_expression_injection() {
5179        assert!(sanitize_css_color("expression(alert(1))").is_err());
5180        assert!(sanitize_css_color("EXPRESSION(alert(1))").is_err());
5181    }
5182
5183    #[test]
5184    fn css_color_rejects_import() {
5185        assert!(sanitize_css_color("@import url(evil.css)").is_err());
5186    }
5187
5188    #[test]
5189    fn css_color_rejects_semicolons_and_braces() {
5190        assert!(sanitize_css_color("red; background: url(evil)").is_err());
5191        assert!(sanitize_css_color("red} body { color: blue").is_err());
5192    }
5193
5194    #[test]
5195    fn css_color_rejects_special_chars() {
5196        assert!(sanitize_css_color("red<script>").is_err());
5197        assert!(sanitize_css_color("red\"onload=alert").is_err());
5198        assert!(sanitize_css_color("red'onclick=alert").is_err());
5199    }
5200
5201    #[test]
5202    fn css_color_trims_whitespace() {
5203        assert_eq!(sanitize_css_color("  red  ").unwrap(), "red");
5204    }
5205
5206    #[test]
5207    fn css_color_empty_string() {
5208        assert_eq!(sanitize_css_color("").unwrap(), "");
5209    }
5210}
5211
5212/// Dispatch-level authorization tests.
5213///
5214/// These exercise the REAL `execute_tool` dispatch path (not just the privacy
5215/// string matrix) to prove that blocked tools/actions actually return
5216/// `tool_disabled` and never reach their handler. This is the negative security
5217/// suite the audit required (Gate #5): the prior tests validated
5218/// `is_tool_enabled(...)` in isolation, which let structural dispatch bypasses
5219/// pass undetected.
5220#[cfg(test)]
5221mod authz_dispatch_tests {
5222    use super::*;
5223    use crate::bridge::WebviewBridge;
5224    use crate::privacy::PrivacyConfig;
5225    use std::collections::{HashMap, HashSet};
5226    use victauri_core::{CommandRegistry, EventLog, EventRecorder, WindowState};
5227
5228    /// A bridge whose eval always fails immediately, so an *allowed* action that
5229    /// reaches the bridge returns a non-privacy error fast (no 30s hang), while a
5230    /// *blocked* action is rejected by dispatch before the bridge is ever touched.
5231    struct RejectingBridge;
5232
5233    impl WebviewBridge for RejectingBridge {
5234        fn eval_webview(&self, _label: Option<&str>, _script: &str) -> Result<(), String> {
5235            Err("eval rejected in authz dispatch test".to_string())
5236        }
5237        fn get_window_states(&self, _label: Option<&str>) -> Vec<WindowState> {
5238            Vec::new()
5239        }
5240        fn list_window_labels(&self) -> Vec<String> {
5241            Vec::new()
5242        }
5243        fn get_native_handle(&self, _label: Option<&str>) -> Result<isize, String> {
5244            Err("no handle".to_string())
5245        }
5246        fn manage_window(&self, _label: Option<&str>, _action: &str) -> Result<String, String> {
5247            Err("no window".to_string())
5248        }
5249        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5250            Ok(())
5251        }
5252        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5253            Ok(())
5254        }
5255        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5256            Ok(())
5257        }
5258    }
5259
5260    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5261        Arc::new(VictauriState {
5262            event_log: EventLog::new(1000),
5263            registry: CommandRegistry::new(),
5264            port: std::sync::atomic::AtomicU16::new(0),
5265            pending_evals: Arc::new(Mutex::new(HashMap::new())),
5266            recorder: EventRecorder::new(1000),
5267            privacy,
5268            eval_timeout: std::time::Duration::from_millis(100),
5269            shutdown_tx: tokio::sync::watch::channel(false).0,
5270            started_at: std::time::Instant::now(),
5271            tool_invocations: std::sync::atomic::AtomicU64::new(0),
5272            allow_file_navigation: false,
5273            command_timings: crate::introspection::CommandTimings::new(),
5274            fault_registry: crate::introspection::FaultRegistry::new(),
5275            contract_store: crate::introspection::ContractStore::new(),
5276            startup_timeline: crate::introspection::StartupTimeline::new(),
5277            event_bus: crate::introspection::EventBusMonitor::default(),
5278            task_tracker: crate::introspection::TaskTracker::new(),
5279            bridge_ready: std::sync::atomic::AtomicBool::new(true),
5280            bridge_notify: tokio::sync::Notify::new(),
5281            db_search_paths: Vec::new(),
5282            screencast: Arc::new(crate::screencast::Screencast::default()),
5283            probes: crate::introspection::AppStateProbes::default(),
5284        })
5285    }
5286
5287    fn handler(privacy: PrivacyConfig) -> VictauriMcpHandler {
5288        VictauriMcpHandler::new(state_with(privacy), Arc::new(RejectingBridge))
5289    }
5290
5291    /// True iff the result is a privacy/authorization block (vs any other error).
5292    fn is_privacy_blocked(r: &CallToolResult) -> bool {
5293        r.is_error == Some(true)
5294            && r.content.iter().any(|c| {
5295                matches!(&c.raw, RawContent::Text(t)
5296                    if t.text.contains("disabled by privacy configuration"))
5297            })
5298    }
5299
5300    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5301        match h.execute_tool(tool, args).await {
5302            Ok(r) => r,
5303            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5304        }
5305    }
5306
5307    // ── Observe profile: every mutation/eval/compound-action must be blocked ──
5308
5309    #[tokio::test]
5310    async fn observe_blocks_mutations_and_eval_through_dispatch() {
5311        let h = handler(crate::privacy::observe_privacy_config());
5312        let blocked: &[(&str, serde_json::Value)] = &[
5313            ("eval_js", serde_json::json!({"code": "1"})),
5314            ("screenshot", serde_json::json!({})),
5315            ("invoke_command", serde_json::json!({"command": "greet"})),
5316            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5317            (
5318                "assert_semantic",
5319                serde_json::json!({"expression": "1", "condition": "truthy"}),
5320            ),
5321            (
5322                "interact",
5323                serde_json::json!({"action": "click", "ref_id": "e1"}),
5324            ),
5325            (
5326                "input",
5327                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5328            ),
5329            (
5330                "storage",
5331                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5332            ),
5333            (
5334                "storage",
5335                serde_json::json!({"action": "delete", "key": "k"}),
5336            ),
5337            (
5338                "window",
5339                serde_json::json!({"action": "manage", "manage_action": "close"}),
5340            ),
5341            (
5342                "window",
5343                serde_json::json!({"action": "set_title", "title": "x"}),
5344            ),
5345            (
5346                "navigate",
5347                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5348            ),
5349            (
5350                "css",
5351                serde_json::json!({"action": "inject", "css": "body{}"}),
5352            ),
5353            ("route", serde_json::json!({"action": "clear_all"})),
5354            ("recording", serde_json::json!({"action": "start"})),
5355            ("recording", serde_json::json!({"action": "replay"})),
5356            ("logs", serde_json::json!({"action": "clear"})),
5357            (
5358                "fault",
5359                serde_json::json!({"action": "inject", "command": "x", "fault_type": "error"}),
5360            ),
5361            (
5362                "introspect",
5363                serde_json::json!({"action": "command_timings"}),
5364            ),
5365        ];
5366        for (tool, args) in blocked {
5367            let r = call(&h, tool, args.clone()).await;
5368            assert!(
5369                is_privacy_blocked(&r),
5370                "Observe must block {tool} {args} at dispatch, got: {:?}",
5371                r.content
5372            );
5373        }
5374    }
5375
5376    #[tokio::test]
5377    async fn observe_allows_read_only_through_dispatch() {
5378        let h = handler(crate::privacy::observe_privacy_config());
5379        // These reads must NOT be privacy-blocked (they may fail for other reasons
5380        // against the rejecting bridge, but never with a privacy block).
5381        let allowed: &[(&str, serde_json::Value)] = &[
5382            ("get_registry", serde_json::json!({})),
5383            ("get_memory_stats", serde_json::json!({})),
5384            ("window", serde_json::json!({"action": "list"})),
5385            ("logs", serde_json::json!({"action": "ipc"})),
5386            (
5387                "inspect",
5388                serde_json::json!({"action": "get_styles", "ref_id": "e1"}),
5389            ),
5390        ];
5391        for (tool, args) in allowed {
5392            let r = call(&h, tool, args.clone()).await;
5393            assert!(
5394                !is_privacy_blocked(&r),
5395                "Observe must allow {tool} {args} at dispatch (blocked unexpectedly)"
5396            );
5397        }
5398    }
5399
5400    // ── Test profile: interactions allowed, eval/replay/route blocked ─────────
5401
5402    #[tokio::test]
5403    async fn test_profile_dispatch_boundaries() {
5404        let h = handler(crate::privacy::test_privacy_config());
5405        // Allowed in Test:
5406        for (tool, args) in [
5407            (
5408                "interact",
5409                serde_json::json!({"action": "click", "ref_id": "e1"}),
5410            ),
5411            (
5412                "input",
5413                serde_json::json!({"action": "fill", "ref_id": "e1", "value": "x"}),
5414            ),
5415            (
5416                "storage",
5417                serde_json::json!({"action": "set", "key": "k", "value": "v"}),
5418            ),
5419            ("navigate", serde_json::json!({"action": "go_back"})),
5420            ("recording", serde_json::json!({"action": "start"})),
5421            ("logs", serde_json::json!({"action": "clear"})),
5422        ] {
5423            let r = call(&h, tool, args.clone()).await;
5424            assert!(!is_privacy_blocked(&r), "Test must allow {tool} {args}");
5425        }
5426        // Blocked in Test (arbitrary eval, navigation mutation, replay, FullControl tools):
5427        for (tool, args) in [
5428            ("eval_js", serde_json::json!({"code": "1"})),
5429            ("verify_state", serde_json::json!({"frontend_expr": "1"})),
5430            (
5431                "navigate",
5432                serde_json::json!({"action": "go_to", "url": "https://e.com"}),
5433            ),
5434            ("recording", serde_json::json!({"action": "replay"})),
5435            (
5436                "route",
5437                serde_json::json!({"action": "add", "pattern": "x"}),
5438            ),
5439            ("css", serde_json::json!({"action": "inject", "css": "x"})),
5440            (
5441                "window",
5442                serde_json::json!({"action": "set_title", "title": "x"}),
5443            ),
5444        ] {
5445            let r = call(&h, tool, args.clone()).await;
5446            assert!(is_privacy_blocked(&r), "Test must block {tool} {args}");
5447        }
5448    }
5449
5450    // ── disabled_tools: bare-name disable covers all of a compound tool's
5451    //    actions, and per-action disable is honored even when the handler
5452    //    historically did not check it (the route.clear bypass). ──────────────
5453
5454    #[tokio::test]
5455    async fn disabling_bare_compound_tool_blocks_all_actions() {
5456        let cfg = PrivacyConfig {
5457            disabled_tools: HashSet::from(["recording".to_string()]),
5458            ..Default::default()
5459        }; // FullControl with the whole `recording` tool disabled
5460        let h = handler(cfg);
5461        for action in ["start", "stop", "replay", "import", "export"] {
5462            let r = call(&h, "recording", serde_json::json!({"action": action})).await;
5463            assert!(
5464                is_privacy_blocked(&r),
5465                "disabling bare `recording` must block recording.{action}"
5466            );
5467        }
5468    }
5469
5470    #[tokio::test]
5471    async fn disabling_specific_action_is_honored_at_dispatch() {
5472        // The historical bypass: `route.clear`'s handler had no per-action check,
5473        // so a `disabled_tools` entry for it was silently ignored. The central
5474        // gate now enforces it.
5475        let cfg = PrivacyConfig {
5476            disabled_tools: HashSet::from([
5477                "route.clear".to_string(),
5478                "route.clear_all".to_string(),
5479            ]),
5480            ..Default::default()
5481        }; // FullControl: everything else allowed
5482        let h = handler(cfg);
5483
5484        let blocked = call(&h, "route", serde_json::json!({"action": "clear", "id": 1})).await;
5485        assert!(is_privacy_blocked(&blocked), "route.clear must be blocked");
5486        let blocked_all = call(&h, "route", serde_json::json!({"action": "clear_all"})).await;
5487        assert!(
5488            is_privacy_blocked(&blocked_all),
5489            "route.clear_all must be blocked"
5490        );
5491
5492        // A sibling action the operator did NOT disable is still reachable.
5493        let allowed = call(&h, "route", serde_json::json!({"action": "list"})).await;
5494        assert!(
5495            !is_privacy_blocked(&allowed),
5496            "route.list must remain allowed"
5497        );
5498    }
5499
5500    // Command-policy enforcement on invoke paths (A1/A2) and resource gating (B1)
5501    // are covered with side-effect detection (a bridge that records actual invokes)
5502    // in the `command_policy_dispatch_tests` module below — that proves the blocked
5503    // command never reaches the bridge, not merely that an error string is returned.
5504
5505    #[tokio::test]
5506    async fn full_control_allows_everything_at_dispatch() {
5507        let h = handler(PrivacyConfig::default());
5508        for (tool, args) in [
5509            ("recording", serde_json::json!({"action": "replay"})),
5510            ("route", serde_json::json!({"action": "clear_all"})),
5511            ("eval_js", serde_json::json!({"code": "1"})),
5512            ("fault", serde_json::json!({"action": "list"})),
5513        ] {
5514            let r = call(&h, tool, args.clone()).await;
5515            assert!(
5516                !is_privacy_blocked(&r),
5517                "FullControl must allow {tool} {args}"
5518            );
5519        }
5520    }
5521}
5522
5523/// Command-policy enforcement on EVERY command-invoking path (audit #30/#31, triage A1/A2).
5524///
5525/// The prior privacy suite validated the permission-string matrix — `is_tool_enabled("x")`
5526/// in isolation — which let structural dispatch bypasses pass undetected (the audit's
5527/// central criticism: "tests validate the STRING MATRIX, not actual dispatch behavior").
5528///
5529/// These tests instead drive the REAL dispatcher with a bridge that records every script
5530/// handed to `eval_webview`, and assert the dangerous **side effect** — the
5531/// `__TAURI_INTERNALS__.invoke(<command>)` script — is NEVER emitted when the command is on
5532/// the operator's blocklist, on each path that invokes commands OUTSIDE `invoke_command`:
5533/// `recording.replay`, `recording.import` + `replay`, `introspect.contract_record`, and
5534/// `introspect.contract_check`. Each has a positive control proving an *allowed* command IS
5535/// invoked (so a blanket-block can't make the negative test pass vacuously).
5536#[cfg(test)]
5537mod command_policy_dispatch_tests {
5538    use super::*;
5539    use crate::bridge::WebviewBridge;
5540    use crate::privacy::PrivacyConfig;
5541    use serde_json::json;
5542    use std::collections::{HashMap, HashSet};
5543    use std::sync::Mutex as StdMutex;
5544    use victauri_core::{
5545        AppEvent, CommandRegistry, EventLog, EventRecorder, IpcCall, IpcResult, RecordedEvent,
5546        RecordedSession, WindowState,
5547    };
5548
5549    /// A bridge that RECORDS every script passed to `eval_webview` (so a test can assert a
5550    /// blocklisted command's invoke was never emitted) then fails the eval fast — an allowed
5551    /// command is observably *attempted* without hanging on a callback that never arrives.
5552    #[derive(Clone, Default)]
5553    struct RecordingBridge {
5554        scripts: Arc<StdMutex<Vec<String>>>,
5555    }
5556
5557    impl RecordingBridge {
5558        /// True iff any recorded eval script invoked `command` via the Tauri IPC bridge.
5559        fn invoked(&self, command: &str) -> bool {
5560            let needle = format!("invoke({}", js_string(command));
5561            self.scripts
5562                .lock()
5563                .unwrap_or_else(std::sync::PoisonError::into_inner)
5564                .iter()
5565                .any(|s| s.contains(&needle))
5566        }
5567    }
5568
5569    impl WebviewBridge for RecordingBridge {
5570        fn eval_webview(&self, _label: Option<&str>, script: &str) -> Result<(), String> {
5571            self.scripts
5572                .lock()
5573                .unwrap_or_else(std::sync::PoisonError::into_inner)
5574                .push(script.to_string());
5575            // Return Ok so `eval_with_return` injects BOTH its watchdog and the
5576            // user-code script (it bails on the first Err). No callback ever fires,
5577            // so the call simply times out at the 100ms test `eval_timeout` — we only
5578            // care WHICH scripts reached the bridge, never the eval's return value.
5579            Ok(())
5580        }
5581        fn get_window_states(&self, _l: Option<&str>) -> Vec<WindowState> {
5582            Vec::new()
5583        }
5584        fn list_window_labels(&self) -> Vec<String> {
5585            Vec::new()
5586        }
5587        fn get_native_handle(&self, _l: Option<&str>) -> Result<isize, String> {
5588            Err("no handle".to_string())
5589        }
5590        fn manage_window(&self, _l: Option<&str>, _a: &str) -> Result<String, String> {
5591            Err("no window".to_string())
5592        }
5593        fn resize_window(&self, _l: Option<&str>, _w: u32, _h: u32) -> Result<(), String> {
5594            Ok(())
5595        }
5596        fn move_window(&self, _l: Option<&str>, _x: i32, _y: i32) -> Result<(), String> {
5597            Ok(())
5598        }
5599        fn set_window_title(&self, _l: Option<&str>, _t: &str) -> Result<(), String> {
5600            Ok(())
5601        }
5602    }
5603
5604    fn state_with(privacy: PrivacyConfig) -> Arc<VictauriState> {
5605        Arc::new(VictauriState {
5606            event_log: EventLog::new(1000),
5607            registry: CommandRegistry::new(),
5608            port: std::sync::atomic::AtomicU16::new(0),
5609            pending_evals: Arc::new(Mutex::new(HashMap::new())),
5610            recorder: EventRecorder::new(1000),
5611            privacy,
5612            eval_timeout: std::time::Duration::from_millis(100),
5613            shutdown_tx: tokio::sync::watch::channel(false).0,
5614            started_at: std::time::Instant::now(),
5615            tool_invocations: std::sync::atomic::AtomicU64::new(0),
5616            allow_file_navigation: false,
5617            command_timings: crate::introspection::CommandTimings::new(),
5618            fault_registry: crate::introspection::FaultRegistry::new(),
5619            contract_store: crate::introspection::ContractStore::new(),
5620            startup_timeline: crate::introspection::StartupTimeline::new(),
5621            event_bus: crate::introspection::EventBusMonitor::default(),
5622            task_tracker: crate::introspection::TaskTracker::new(),
5623            bridge_ready: std::sync::atomic::AtomicBool::new(true),
5624            bridge_notify: tokio::sync::Notify::new(),
5625            db_search_paths: Vec::new(),
5626            screencast: Arc::new(crate::screencast::Screencast::default()),
5627            probes: crate::introspection::AppStateProbes::default(),
5628        })
5629    }
5630
5631    // FullControl, except the named commands are blocklisted — exactly the scenario
5632    // the audit flagged: an operator who trusts `command_blocklist` to stop a
5633    // dangerous command.
5634    fn blocking(cmds: &[&str]) -> PrivacyConfig {
5635        PrivacyConfig {
5636            command_blocklist: cmds.iter().map(|s| (*s).to_string()).collect(),
5637            ..Default::default()
5638        }
5639    }
5640
5641    fn ipc_event(command: &str) -> AppEvent {
5642        AppEvent::Ipc(IpcCall {
5643            id: format!("c-{command}"),
5644            command: command.to_string(),
5645            timestamp: chrono::Utc::now(),
5646            duration_ms: Some(1),
5647            result: IpcResult::Ok(json!(true)),
5648            arg_size_bytes: 0,
5649            webview_label: "main".to_string(),
5650        })
5651    }
5652
5653    fn result_text(r: &CallToolResult) -> String {
5654        r.content
5655            .iter()
5656            .filter_map(|c| match &c.raw {
5657                RawContent::Text(t) => Some(t.text.clone()),
5658                _ => None,
5659            })
5660            .collect::<Vec<_>>()
5661            .join("\n")
5662    }
5663
5664    async fn call(h: &VictauriMcpHandler, tool: &str, args: serde_json::Value) -> CallToolResult {
5665        match h.execute_tool(tool, args).await {
5666            Ok(r) => r,
5667            Err(_) => panic!("dispatch returned a transport error (arg parse failure)"),
5668        }
5669    }
5670
5671    // ── recording.replay (audit #30/#31, A1) ─────────────────────────────────
5672
5673    #[tokio::test]
5674    async fn replay_never_invokes_a_blocklisted_command() {
5675        let bridge = RecordingBridge::default();
5676        let state = state_with(blocking(&["delete_account"]));
5677        state.recorder.start("s1".to_string()).unwrap();
5678        state.recorder.record_event(ipc_event("delete_account"));
5679        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5680
5681        let r = call(&h, "recording", json!({"action": "replay"})).await;
5682
5683        assert!(
5684            !bridge.invoked("delete_account"),
5685            "SIDE-EFFECT LEAK: replay handed a blocklisted command's invoke to the bridge (audit #30/#31)"
5686        );
5687        assert!(
5688            result_text(&r).contains("blocked"),
5689            "replay should report the command as blocked, got: {}",
5690            result_text(&r)
5691        );
5692    }
5693
5694    #[tokio::test]
5695    async fn replay_does_invoke_an_allowed_command() {
5696        // Positive control: proves the negative test isn't vacuous (the path really
5697        // reaches the bridge for a permitted command).
5698        let bridge = RecordingBridge::default();
5699        let state = state_with(PrivacyConfig::default());
5700        state.recorder.start("s1".to_string()).unwrap();
5701        state.recorder.record_event(ipc_event("greet"));
5702        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5703
5704        let _ = call(&h, "recording", json!({"action": "replay"})).await;
5705
5706        assert!(
5707            bridge.invoked("greet"),
5708            "positive control failed: an ALLOWED command was not invoked, so the negative test proves nothing"
5709        );
5710    }
5711
5712    #[tokio::test]
5713    async fn imported_session_cannot_invoke_a_blocklisted_command() {
5714        // audit #31: a crafted session handed to an agent ("replay this to reproduce")
5715        // must not become arbitrary command invocation.
5716        let bridge = RecordingBridge::default();
5717        let state = state_with(blocking(&["wipe_database"]));
5718        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5719
5720        let session = RecordedSession {
5721            id: "poisoned".to_string(),
5722            started_at: chrono::Utc::now(),
5723            events: vec![RecordedEvent {
5724                index: 0,
5725                timestamp: chrono::Utc::now(),
5726                event: ipc_event("wipe_database"),
5727            }],
5728            checkpoints: Vec::new(),
5729        };
5730        let session_json = serde_json::to_string(&session).unwrap();
5731
5732        let imp = call(
5733            &h,
5734            "recording",
5735            json!({"action": "import", "session_json": session_json}),
5736        )
5737        .await;
5738        assert_ne!(
5739            imp.is_error,
5740            Some(true),
5741            "import itself should succeed: {}",
5742            result_text(&imp)
5743        );
5744
5745        let r = call(&h, "recording", json!({"action": "replay"})).await;
5746        assert!(
5747            !bridge.invoked("wipe_database"),
5748            "SIDE-EFFECT LEAK: an imported session replayed a blocklisted command (audit #31)"
5749        );
5750        assert!(result_text(&r).contains("blocked"));
5751    }
5752
5753    // ── introspect.contract_record / contract_check (audit #30, A2) ───────────
5754
5755    #[tokio::test]
5756    async fn contract_record_never_invokes_a_blocklisted_command() {
5757        let bridge = RecordingBridge::default();
5758        let state = state_with(blocking(&["delete_account"]));
5759        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5760
5761        let r = call(
5762            &h,
5763            "introspect",
5764            json!({"action": "contract_record", "command": "delete_account", "args": {"confirm": true}}),
5765        )
5766        .await;
5767
5768        assert!(
5769            !bridge.invoked("delete_account"),
5770            "SIDE-EFFECT LEAK: contract_record invoked a blocklisted command (audit #30)"
5771        );
5772        assert_eq!(r.is_error, Some(true));
5773        assert!(
5774            result_text(&r).contains("blocked by privacy configuration"),
5775            "got: {}",
5776            result_text(&r)
5777        );
5778    }
5779
5780    #[tokio::test]
5781    async fn contract_record_does_invoke_an_allowed_command() {
5782        let bridge = RecordingBridge::default();
5783        let state = state_with(PrivacyConfig::default());
5784        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5785
5786        let _ = call(
5787            &h,
5788            "introspect",
5789            json!({"action": "contract_record", "command": "get_settings"}),
5790        )
5791        .await;
5792
5793        assert!(
5794            bridge.invoked("get_settings"),
5795            "positive control failed: contract_record did not invoke an allowed command"
5796        );
5797    }
5798
5799    #[tokio::test]
5800    async fn contract_check_never_reinvokes_a_now_blocklisted_command() {
5801        // A baseline recorded before the command was blocked must not be re-invoked
5802        // once the operator adds it to the blocklist (audit #30).
5803        let bridge = RecordingBridge::default();
5804        let state = state_with(blocking(&["delete_account"]));
5805        state
5806            .contract_store
5807            .record(crate::introspection::ContractBaseline {
5808                command: "delete_account".to_string(),
5809                args: json!({}),
5810                shape: crate::introspection::JsonShape::from_value(&json!(true)),
5811                sample: "true".to_string(),
5812                recorded_at: chrono_now(),
5813            });
5814        let h = VictauriMcpHandler::new(state, Arc::new(bridge.clone()));
5815
5816        let _ = call(&h, "introspect", json!({"action": "contract_check"})).await;
5817
5818        assert!(
5819            !bridge.invoked("delete_account"),
5820            "SIDE-EFFECT LEAK: contract_check re-invoked a now-blocklisted command (audit #30)"
5821        );
5822    }
5823
5824    // ── MCP resources honour the privacy gate (audit B1) ──────────────────────
5825
5826    #[test]
5827    fn resource_reads_are_gated_by_their_mirrored_capability() {
5828        // Resources bypass the tool dispatcher, so the read path must apply the same
5829        // gate. Disabling the capability a resource mirrors must block the resource.
5830        let cfg = PrivacyConfig {
5831            disabled_tools: HashSet::from([
5832                "logs.ipc".to_string(),
5833                "window.list".to_string(),
5834                "get_plugin_info".to_string(),
5835            ]),
5836            ..Default::default()
5837        };
5838        for uri in [
5839            RESOURCE_URI_IPC_LOG,
5840            RESOURCE_URI_WINDOWS,
5841            RESOURCE_URI_STATE,
5842        ] {
5843            let cap = resource_required_capability(uri).expect("resource maps to a capability");
5844            assert!(
5845                !cfg.is_tool_enabled(cap),
5846                "disabling capability {cap} must gate resource {uri} (audit B1)"
5847            );
5848        }
5849        // Sanity: with nothing disabled, all three resources read.
5850        let full = PrivacyConfig::default();
5851        for uri in [
5852            RESOURCE_URI_IPC_LOG,
5853            RESOURCE_URI_WINDOWS,
5854            RESOURCE_URI_STATE,
5855        ] {
5856            assert!(full.is_tool_enabled(resource_required_capability(uri).unwrap()));
5857        }
5858    }
5859
5860    // ── empty/whitespace auth token collapses to NO auth (audit B2) ───────────
5861
5862    #[tokio::test]
5863    async fn empty_auth_token_collapses_to_no_auth() {
5864        use http_body_util::BodyExt;
5865        use tower::ServiceExt;
5866
5867        for token in [Some(String::new()), Some("   ".to_string())] {
5868            let app = crate::mcp::server::build_app_full(
5869                state_with(PrivacyConfig::default()),
5870                Arc::new(RecordingBridge::default()),
5871                token.clone(),
5872                None,
5873            );
5874            let req = axum::extract::Request::builder()
5875                .uri("/info")
5876                .header("host", "127.0.0.1")
5877                .body(axum::body::Body::empty())
5878                .unwrap();
5879            let resp = app.oneshot(req).await.unwrap();
5880            assert_eq!(
5881                resp.status(),
5882                200,
5883                "/info must be reachable with empty token {token:?} (no auth layer)"
5884            );
5885            let bytes = resp.into_body().collect().await.unwrap().to_bytes();
5886            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5887            assert_eq!(
5888                body["auth_required"],
5889                json!(false),
5890                "empty/whitespace token must report auth_required:false, not looks-protected-isnt (audit B2); token={token:?}"
5891            );
5892        }
5893    }
5894
5895    // ── app_info env allowlist drops secrets (audit #5/B3) ────────────────────
5896
5897    #[test]
5898    fn is_safe_env_key_drops_secrets_keeps_safe() {
5899        for secret in [
5900            "VICTAURI_AUTH_TOKEN",
5901            "TAURI_SIGNING_PRIVATE_KEY",
5902            "TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
5903            "CARGO_REGISTRY_TOKEN",
5904            "AWS_SECRET_ACCESS_KEY",
5905            "DATABASE_DSN",
5906            "GH_PAT",
5907        ] {
5908            assert!(
5909                !is_safe_env_key(secret),
5910                "{secret} is secret-shaped and must NOT be surfaced by app_info (audit #5)"
5911            );
5912        }
5913        for safe in [
5914            "HOME",
5915            "LANG",
5916            "TERM",
5917            "XDG_RUNTIME_DIR",
5918            "TAURI_ENV_PLATFORM",
5919        ] {
5920            assert!(
5921                is_safe_env_key(safe),
5922                "{safe} should be surfaced by app_info"
5923            );
5924        }
5925    }
5926}