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