Skip to main content

victauri_plugin/mcp/
mod.rs

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