Skip to main content

victauri_plugin/
privacy.rs

1use std::collections::HashSet;
2
3use crate::redaction::Redactor;
4
5/// Privacy profile controlling which MCP tools and actions are permitted.
6///
7/// The three tiers form a strict hierarchy: `Observe ⊂ Test ⊂ FullControl`.
8/// Each higher tier inherits all permissions from the tier below and adds more.
9///
10/// | Profile | Can read | Can interact | Can mutate | Can eval/screenshot |
11/// |---|---|---|---|---|
12/// | `Observe` | Yes | No | No | No |
13/// | `Test` | Yes | Yes | Storage writes | No |
14/// | `FullControl` | Yes | Yes | Yes | Yes |
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub enum PrivacyProfile {
18    /// Read-only observation. Snapshots, logs, registry, accessibility, performance,
19    /// window state — but no clicks, no input, no eval, no screenshots, no mutations.
20    Observe,
21    /// Observation + UI interactions + storage writes + recording. Suitable for
22    /// automated testing. Eval, screenshot, CSS injection, navigation, and
23    /// `invoke_command` (unless allowlisted) remain blocked.
24    Test,
25    /// Everything permitted. No restrictions. This is the default.
26    #[default]
27    FullControl,
28}
29
30impl std::fmt::Display for PrivacyProfile {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::Observe => write!(f, "observe"),
34            Self::Test => write!(f, "test"),
35            Self::FullControl => write!(f, "full_control"),
36        }
37    }
38}
39
40/// Privacy controls for the MCP server.
41///
42/// Combines a [`PrivacyProfile`] (tiered permission matrix) with fine-grained
43/// overrides: command allowlists/blocklists, per-tool disabling, and output redaction.
44///
45/// **Precedence:** explicit `disabled_tools` overrides → profile matrix → allowlist/blocklist.
46///
47/// `#[non_exhaustive]`: start from [`PrivacyConfig::default()`] (or one of the
48/// profile presets) and assign the `pub` fields you need.
49#[derive(Default)]
50#[non_exhaustive]
51pub struct PrivacyConfig {
52    /// The active privacy profile tier.
53    pub profile: PrivacyProfile,
54    /// If set, only these Tauri commands can be invoked (positive allowlist).
55    pub command_allowlist: Option<HashSet<String>>,
56    /// Tauri commands that are always blocked, even if on the allowlist.
57    pub command_blocklist: HashSet<String>,
58    /// MCP tool/action names explicitly disabled (override layer on top of profile).
59    pub disabled_tools: HashSet<String>,
60    /// localStorage/sessionStorage keys that `storage.set` must never write
61    /// (operator-configured, since which keys carry app trust is app-specific).
62    pub storage_key_blocklist: HashSet<String>,
63    /// Output redactor with regex and JSON-key matching.
64    pub redactor: Redactor,
65    /// Whether output redaction is active.
66    pub redaction_enabled: bool,
67}
68
69impl PrivacyConfig {
70    /// Returns `true` if the Tauri command passes both the allowlist and blocklist.
71    #[must_use]
72    pub fn is_command_allowed(&self, command: &str) -> bool {
73        if self.command_blocklist.contains(command) {
74            return false;
75        }
76        match &self.command_allowlist {
77            Some(allow) => allow.contains(command),
78            None => true,
79        }
80    }
81
82    /// Returns `true` if `storage.set` may write the given storage key (i.e. the
83    /// key is not in the operator's `storage_key_blocklist`). Use this to protect
84    /// keys an app trusts for auth/role/tier/feature-flag decisions (audit #33).
85    #[must_use]
86    pub fn is_storage_key_allowed(&self, key: &str) -> bool {
87        !self.storage_key_blocklist.contains(key)
88    }
89
90    /// Returns `true` if the given tool or qualified action (e.g. `"window.manage"`)
91    /// is permitted by the current profile AND not in the explicit disabled set.
92    #[must_use]
93    pub fn is_tool_enabled(&self, tool_or_action: &str) -> bool {
94        if self.is_disabled(tool_or_action) {
95            return false;
96        }
97        is_allowed_by_profile(self.profile, tool_or_action)
98    }
99
100    /// Whether `name` is in `disabled_tools` under either of its spellings: a few
101    /// capability ids differ from their `tool.action` name (`inspect.styles` is the
102    /// `inspect` tool's `get_styles` action), and an operator may write either.
103    fn is_disabled(&self, name: &str) -> bool {
104        self.disabled_tools.contains(name)
105            || crate::mcp::authz::capability_alias(name)
106                .is_some_and(|alias| self.disabled_tools.contains(alias))
107    }
108
109    /// Authoritative dispatch gate for a tool call.
110    ///
111    /// `bare_tool` is the top-level tool name (e.g. `"recording"`); `capability`
112    /// is the canonical matrix identity for the specific action (e.g.
113    /// `"recording.replay"`), as resolved by
114    /// `mcp::authz::canonical_capability`. A call is allowed only when:
115    ///
116    /// 1. the operator has not explicitly disabled the whole tool by its bare name
117    ///    (`disable_tool("recording")` must block every `recording.*` action); AND
118    /// 2. the resolved capability is permitted by the profile and not itself in
119    ///    `disabled_tools`.
120    ///
121    /// This is what closes the per-action authorization gap: an action whose
122    /// handler forgot to check it is still gated here, and a bare-name disable now
123    /// covers all of a compound tool's actions.
124    #[must_use]
125    pub fn is_call_allowed(&self, bare_tool: &str, capability: &str) -> bool {
126        if self.disabled_tools.contains(bare_tool) {
127            return false;
128        }
129        self.is_tool_enabled(capability)
130    }
131
132    /// Check whether `invoke_command` is allowed for a specific command name.
133    ///
134    /// In `Test` profile, `invoke_command` is only allowed if the command is on the
135    /// allowlist. In `FullControl`, it's always allowed. In `Observe`, always blocked.
136    #[must_use]
137    pub fn is_invoke_allowed(&self, command: &str) -> bool {
138        if self.disabled_tools.contains("invoke_command") {
139            return false;
140        }
141        match self.profile {
142            PrivacyProfile::FullControl => true,
143            PrivacyProfile::Test => self
144                .command_allowlist
145                .as_ref()
146                .is_some_and(|al| al.contains(command)),
147            PrivacyProfile::Observe => false,
148        }
149    }
150
151    /// Apply redaction rules to the output string if redaction is enabled.
152    #[must_use]
153    pub fn redact_output(&self, output: &str) -> String {
154        if self.redaction_enabled {
155            self.redactor.redact(output)
156        } else {
157            output.to_string()
158        }
159    }
160}
161
162/// The permission matrix. Maps `(profile, tool_or_action)` → allowed.
163///
164/// Naming convention: standalone tools use bare names (`"eval_js"`), compound tool
165/// actions use dot-qualified names (`"window.manage"`, `"input.fill"`).
166///
167/// `FullControl` allows everything. `Test` and `Observe` are **deny-by-default**:
168/// only the tools/actions explicitly listed for that profile are allowed, so a new
169/// tool stays blocked under them until it is added here.
170#[must_use]
171fn is_allowed_by_profile(profile: PrivacyProfile, tool_or_action: &str) -> bool {
172    match profile {
173        PrivacyProfile::FullControl => true,
174        PrivacyProfile::Test => matches!(
175            tool_or_action,
176            // Read-only observation (superset of Observe)
177            "dom_snapshot"
178                | "find_elements"
179                | "get_registry"
180                | "get_memory_stats"
181                | "get_plugin_info"
182                | "get_diagnostics"
183                | "detect_ghost_commands"
184                | "check_ipc_integrity"
185                | "resolve_command"
186                | "wait_for"
187                | "app_state"
188                // NOTE: verify_state and assert_semantic are intentionally NOT in
189                // the Test allowlist. Both execute a caller-supplied JS expression
190                // (`frontend_expr` / `expression`) — that is arbitrary eval, which
191                // the Test profile forbids. Listing them here advertised a
192                // capability that the eval_js gate then always rejected (audit B4).
193                // They remain available in FullControl.
194                // Interactions
195                | "interact"
196                | "interact.click"
197                | "interact.double_click"
198                | "interact.hover"
199                | "interact.focus"
200                | "interact.scroll_into_view"
201                | "interact.select_option"
202                // Input
203                | "fill"
204                | "input"
205                | "input.fill"
206                | "type_text"
207                | "input.type_text"
208                | "input.press_key"
209                // Storage (read + write)
210                | "storage"
211                | "set_storage"
212                | "storage.set"
213                | "delete_storage"
214                | "storage.delete"
215                | "storage.get"
216                | "storage.get_cookies"
217                | "get_storage"
218                | "get_cookies"
219                // Recording
220                | "recording"
221                | "recording.start"
222                | "recording.stop"
223                | "recording.checkpoint"
224                | "recording.list_checkpoints"
225                | "recording.get_events"
226                | "recording.events_between"
227                | "recording.get_replay"
228                | "recording.export"
229                | "recording.import"
230                // Logs (all read-only)
231                | "logs"
232                | "logs.console"
233                | "logs.network"
234                | "logs.ipc"
235                | "logs.navigation"
236                | "logs.dialogs"
237                | "logs.events"
238                | "logs.slow_ipc"
239                | "logs.backend"
240                | "logs.backend_digest"
241                | "logs.stdout"
242                // logs.clear erases captured logs — a test-time mutation, allowed
243                // in Test/FullControl but NOT in read-only Observe.
244                | "logs.clear"
245                // Inspect (read-only + visual debug)
246                | "inspect"
247                | "inspect.styles"
248                | "inspect.bounds"
249                | "inspect.highlight"
250                | "inspect.clear_highlights"
251                | "inspect.audit_a11y"
252                | "inspect.performance"
253                // Window (read-only)
254                | "list_windows"
255                | "window"
256                | "window.get_state"
257                | "window.list"
258                | "window.introspectability"
259                | "get_window_state"
260                // Navigate (read-only actions). The bare `navigate` name is listed
261                // so the tool surfaces in `list_tools`; the per-action matrix below
262                // (and the centralized authz resolver) is what gates `go_to` /
263                // `set_dialog_response` (mutations) to FullControl only.
264                | "navigate"
265                | "navigate.go_back"
266                | "navigate.get_history"
267                | "navigate.get_dialog_log"
268        ),
269        PrivacyProfile::Observe => matches!(
270            tool_or_action,
271            "dom_snapshot"
272                | "find_elements"
273                | "get_registry"
274                | "get_memory_stats"
275                | "get_plugin_info"
276                | "get_diagnostics"
277                | "detect_ghost_commands"
278                | "check_ipc_integrity"
279                | "resolve_command"
280                | "app_state"
281                | "logs"
282                | "logs.console"
283                | "logs.network"
284                | "logs.ipc"
285                | "logs.navigation"
286                | "logs.dialogs"
287                | "logs.events"
288                | "logs.slow_ipc"
289                | "logs.backend"
290                | "logs.backend_digest"
291                | "logs.stdout"
292                // NOTE: logs.clear is intentionally excluded — clearing logs erases
293                // observable evidence, which the read-only Observe profile forbids.
294                | "inspect"
295                | "inspect.styles"
296                | "inspect.bounds"
297                // NOTE: inspect.highlight / clear_highlights are intentionally excluded —
298                // they inject/remove DOM overlay nodes, a mutation Observe forbids.
299                | "inspect.audit_a11y"
300                | "inspect.performance"
301                | "list_windows"
302                | "window"
303                | "window.get_state"
304                | "window.list"
305                | "window.introspectability"
306                | "get_window_state"
307                | "wait_for"
308        ),
309    }
310}
311
312/// Create a [`PrivacyConfig`] for the `Observe` profile with redaction enabled.
313#[must_use]
314#[doc(hidden)]
315pub fn observe_privacy_config() -> PrivacyConfig {
316    PrivacyConfig {
317        profile: PrivacyProfile::Observe,
318        command_allowlist: None,
319        command_blocklist: HashSet::new(),
320        disabled_tools: HashSet::new(),
321        storage_key_blocklist: HashSet::new(),
322        redactor: Redactor::default(),
323        redaction_enabled: true,
324    }
325}
326
327/// Create a [`PrivacyConfig`] for the `Test` profile with redaction enabled.
328#[must_use]
329#[doc(hidden)]
330pub fn test_privacy_config() -> PrivacyConfig {
331    PrivacyConfig {
332        profile: PrivacyProfile::Test,
333        command_allowlist: None,
334        command_blocklist: HashSet::new(),
335        disabled_tools: HashSet::new(),
336        storage_key_blocklist: HashSet::new(),
337        redactor: Redactor::default(),
338        redaction_enabled: true,
339    }
340}
341
342/// Create a [`PrivacyConfig`] that disables dangerous tools and enables redaction.
343///
344/// This is an alias for [`observe_privacy_config()`] — strict mode maps to the
345/// `Observe` profile.
346#[must_use]
347#[doc(hidden)]
348pub fn strict_privacy_config() -> PrivacyConfig {
349    observe_privacy_config()
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    // ── Command filtering ──────────────────────────────────────────────────
357
358    #[test]
359    fn storage_key_blocklist_protects_keys() {
360        let mut config = PrivacyConfig::default();
361        // Default: every key is writable.
362        assert!(config.is_storage_key_allowed("auth"));
363        // Once blocked, protected keys are rejected; others still allowed.
364        config.storage_key_blocklist = ["auth", "license_tier"]
365            .iter()
366            .map(ToString::to_string)
367            .collect();
368        assert!(!config.is_storage_key_allowed("auth"));
369        assert!(!config.is_storage_key_allowed("license_tier"));
370        assert!(config.is_storage_key_allowed("theme"));
371    }
372
373    #[test]
374    fn default_allows_all_commands() {
375        let config = PrivacyConfig::default();
376        assert!(config.is_command_allowed("get_settings"));
377        assert!(config.is_command_allowed("anything"));
378    }
379
380    #[test]
381    fn blocklist_blocks() {
382        let mut config = PrivacyConfig::default();
383        config.command_blocklist.insert("save_api_key".to_string());
384        assert!(!config.is_command_allowed("save_api_key"));
385        assert!(config.is_command_allowed("get_settings"));
386    }
387
388    #[test]
389    fn allowlist_restricts() {
390        let mut allow = HashSet::new();
391        allow.insert("get_settings".to_string());
392        allow.insert("get_monitoring_status".to_string());
393        let config = PrivacyConfig {
394            command_allowlist: Some(allow),
395            ..Default::default()
396        };
397        assert!(config.is_command_allowed("get_settings"));
398        assert!(!config.is_command_allowed("save_api_key"));
399    }
400
401    #[test]
402    fn blocklist_wins_over_allowlist() {
403        let mut allow = HashSet::new();
404        allow.insert("save_api_key".to_string());
405        let mut block = HashSet::new();
406        block.insert("save_api_key".to_string());
407        let config = PrivacyConfig {
408            command_allowlist: Some(allow),
409            command_blocklist: block,
410            ..Default::default()
411        };
412        assert!(!config.is_command_allowed("save_api_key"));
413    }
414
415    // ── Profile: FullControl ───────────────────────────────────────────────
416
417    #[test]
418    fn full_control_allows_everything() {
419        let config = PrivacyConfig::default();
420        assert_eq!(config.profile, PrivacyProfile::FullControl);
421        assert!(config.is_tool_enabled("eval_js"));
422        assert!(config.is_tool_enabled("screenshot"));
423        assert!(config.is_tool_enabled("invoke_command"));
424        assert!(config.is_tool_enabled("interact"));
425        assert!(config.is_tool_enabled("interact.click"));
426        assert!(config.is_tool_enabled("input.fill"));
427        assert!(config.is_tool_enabled("window.manage"));
428        assert!(config.is_tool_enabled("navigate"));
429        assert!(config.is_tool_enabled("navigate.go_to"));
430        assert!(config.is_tool_enabled("css.inject"));
431        assert!(config.is_tool_enabled("recording"));
432        assert!(config.is_tool_enabled("storage.set"));
433        assert!(config.is_tool_enabled("set_dialog_response"));
434    }
435
436    // ── Profile: Test ──────────────────────────────────────────────────────
437
438    #[test]
439    fn test_profile_allows_interactions() {
440        let config = test_privacy_config();
441        assert!(config.is_tool_enabled("interact"));
442        assert!(config.is_tool_enabled("interact.click"));
443        assert!(config.is_tool_enabled("interact.double_click"));
444        assert!(config.is_tool_enabled("interact.hover"));
445        assert!(config.is_tool_enabled("interact.focus"));
446        assert!(config.is_tool_enabled("interact.scroll_into_view"));
447        assert!(config.is_tool_enabled("interact.select_option"));
448    }
449
450    #[test]
451    fn test_profile_allows_input() {
452        let config = test_privacy_config();
453        assert!(config.is_tool_enabled("fill"));
454        assert!(config.is_tool_enabled("input.fill"));
455        assert!(config.is_tool_enabled("type_text"));
456        assert!(config.is_tool_enabled("input.type_text"));
457        assert!(config.is_tool_enabled("input.press_key"));
458    }
459
460    #[test]
461    fn test_profile_allows_storage_writes() {
462        let config = test_privacy_config();
463        assert!(config.is_tool_enabled("set_storage"));
464        assert!(config.is_tool_enabled("storage.set"));
465        assert!(config.is_tool_enabled("delete_storage"));
466        assert!(config.is_tool_enabled("storage.delete"));
467    }
468
469    #[test]
470    fn test_profile_allows_recording() {
471        let config = test_privacy_config();
472        assert!(config.is_tool_enabled("recording"));
473        assert!(config.is_tool_enabled("recording.start"));
474        assert!(config.is_tool_enabled("recording.stop"));
475    }
476
477    #[test]
478    fn test_profile_blocks_eval_and_screenshot() {
479        let config = test_privacy_config();
480        assert!(!config.is_tool_enabled("eval_js"));
481        assert!(!config.is_tool_enabled("screenshot"));
482    }
483
484    #[test]
485    fn test_profile_blocks_navigation_mutations() {
486        let config = test_privacy_config();
487        // The bare `navigate` tool surfaces in list_tools (read actions are
488        // allowed), but the mutating navigation actions stay FullControl-only —
489        // the centralized authz resolver gates on these per-action identities.
490        assert!(config.is_tool_enabled("navigate"));
491        assert!(!config.is_tool_enabled("navigate.go_to"));
492        assert!(!config.is_tool_enabled("set_dialog_response"));
493        assert!(!config.is_tool_enabled("navigate.set_dialog_response"));
494    }
495
496    #[test]
497    fn test_profile_blocks_window_mutations() {
498        let config = test_privacy_config();
499        assert!(!config.is_tool_enabled("window.manage"));
500        assert!(!config.is_tool_enabled("window.resize"));
501        assert!(!config.is_tool_enabled("window.move_to"));
502        assert!(!config.is_tool_enabled("window.set_title"));
503    }
504
505    #[test]
506    fn test_profile_blocks_css_injection() {
507        let config = test_privacy_config();
508        assert!(!config.is_tool_enabled("inject_css"));
509        assert!(!config.is_tool_enabled("css.inject"));
510        assert!(!config.is_tool_enabled("css.remove"));
511    }
512
513    #[test]
514    fn test_profile_blocks_invoke_command() {
515        let config = test_privacy_config();
516        assert!(!config.is_tool_enabled("invoke_command"));
517    }
518
519    #[test]
520    fn test_profile_allows_read_only_tools() {
521        let config = test_privacy_config();
522        assert!(config.is_tool_enabled("dom_snapshot"));
523        assert!(config.is_tool_enabled("find_elements"));
524        assert!(config.is_tool_enabled("detect_ghost_commands"));
525        assert!(config.is_tool_enabled("check_ipc_integrity"));
526        assert!(config.is_tool_enabled("get_registry"));
527        assert!(config.is_tool_enabled("get_memory_stats"));
528        assert!(config.is_tool_enabled("get_plugin_info"));
529        assert!(config.is_tool_enabled("resolve_command"));
530        assert!(config.is_tool_enabled("wait_for"));
531    }
532
533    #[test]
534    fn test_profile_blocks_arbitrary_eval_assertions() {
535        // verify_state and assert_semantic run a caller-supplied JS expression,
536        // i.e. arbitrary eval, which the Test profile forbids (audit B4).
537        let config = test_privacy_config();
538        assert!(!config.is_tool_enabled("verify_state"));
539        assert!(!config.is_tool_enabled("assert_semantic"));
540    }
541
542    #[test]
543    fn test_profile_allows_navigation_reads_and_introspectability() {
544        // B4 fix: per-action navigation reads must be reachable in Test (the bare
545        // `navigate` name is now listed so the central authz check resolves them).
546        let config = test_privacy_config();
547        assert!(config.is_tool_enabled("navigate.go_back"));
548        assert!(config.is_tool_enabled("navigate.get_history"));
549        assert!(config.is_tool_enabled("navigate.get_dialog_log"));
550        assert!(config.is_tool_enabled("window.introspectability"));
551        // Navigation mutations stay FullControl-only.
552        assert!(!config.is_tool_enabled("navigate.go_to"));
553        assert!(!config.is_tool_enabled("navigate.set_dialog_response"));
554    }
555
556    // ── Profile: Observe ───────────────────────────────────────────────────
557
558    #[test]
559    fn observe_blocks_all_interactions() {
560        let config = observe_privacy_config();
561        assert!(!config.is_tool_enabled("interact"));
562        assert!(!config.is_tool_enabled("interact.click"));
563        assert!(!config.is_tool_enabled("interact.double_click"));
564        assert!(!config.is_tool_enabled("interact.hover"));
565        assert!(!config.is_tool_enabled("interact.focus"));
566        assert!(!config.is_tool_enabled("interact.scroll_into_view"));
567        assert!(!config.is_tool_enabled("interact.select_option"));
568    }
569
570    #[test]
571    fn observe_blocks_all_input() {
572        let config = observe_privacy_config();
573        assert!(!config.is_tool_enabled("fill"));
574        assert!(!config.is_tool_enabled("input.fill"));
575        assert!(!config.is_tool_enabled("type_text"));
576        assert!(!config.is_tool_enabled("input.type_text"));
577        assert!(!config.is_tool_enabled("input.press_key"));
578    }
579
580    #[test]
581    fn observe_blocks_storage_writes() {
582        let config = observe_privacy_config();
583        assert!(!config.is_tool_enabled("set_storage"));
584        assert!(!config.is_tool_enabled("storage.set"));
585        assert!(!config.is_tool_enabled("delete_storage"));
586        assert!(!config.is_tool_enabled("storage.delete"));
587    }
588
589    #[test]
590    fn observe_blocks_recording() {
591        let config = observe_privacy_config();
592        assert!(!config.is_tool_enabled("recording"));
593        assert!(!config.is_tool_enabled("recording.start"));
594        assert!(!config.is_tool_enabled("recording.stop"));
595    }
596
597    #[test]
598    fn observe_blocks_dangerous_tools() {
599        let config = observe_privacy_config();
600        assert!(!config.is_tool_enabled("eval_js"));
601        assert!(!config.is_tool_enabled("screenshot"));
602        assert!(!config.is_tool_enabled("invoke_command"));
603        assert!(!config.is_tool_enabled("navigate"));
604        assert!(!config.is_tool_enabled("navigate.go_to"));
605        assert!(!config.is_tool_enabled("inject_css"));
606        assert!(!config.is_tool_enabled("css.inject"));
607        assert!(!config.is_tool_enabled("css.remove"));
608        assert!(!config.is_tool_enabled("window.manage"));
609        assert!(!config.is_tool_enabled("window.resize"));
610        assert!(!config.is_tool_enabled("window.move_to"));
611        assert!(!config.is_tool_enabled("window.set_title"));
612    }
613
614    #[test]
615    fn observe_allows_read_only_tools() {
616        let config = observe_privacy_config();
617        assert!(config.is_tool_enabled("dom_snapshot"));
618        assert!(config.is_tool_enabled("find_elements"));
619        assert!(config.is_tool_enabled("detect_ghost_commands"));
620        assert!(config.is_tool_enabled("check_ipc_integrity"));
621        assert!(config.is_tool_enabled("get_registry"));
622        assert!(config.is_tool_enabled("get_memory_stats"));
623        assert!(config.is_tool_enabled("get_plugin_info"));
624        assert!(config.is_tool_enabled("get_diagnostics"));
625        assert!(config.is_tool_enabled("resolve_command"));
626        assert!(config.is_tool_enabled("wait_for"));
627        assert!(config.is_tool_enabled("window")); // the compound tool itself (get_state, list)
628    }
629
630    #[test]
631    fn observe_blocks_eval_dependent_tools() {
632        let config = observe_privacy_config();
633        // verify_state and assert_semantic depend on eval_js internally,
634        // so they are excluded from the Observe allowlist.
635        assert!(!config.is_tool_enabled("verify_state"));
636        assert!(!config.is_tool_enabled("assert_semantic"));
637    }
638
639    #[test]
640    fn observe_allows_read_actions_on_compound_tools() {
641        let config = observe_privacy_config();
642        // Window read actions
643        assert!(config.is_tool_enabled("window.get_state"));
644        assert!(config.is_tool_enabled("window.list"));
645        // Logs (all read-only)
646        assert!(config.is_tool_enabled("logs"));
647        assert!(config.is_tool_enabled("logs.console"));
648        assert!(config.is_tool_enabled("logs.network"));
649        assert!(config.is_tool_enabled("logs.ipc"));
650        assert!(config.is_tool_enabled("logs.navigation"));
651        assert!(config.is_tool_enabled("logs.dialogs"));
652        assert!(config.is_tool_enabled("logs.events"));
653        assert!(config.is_tool_enabled("logs.slow_ipc"));
654        // Inspect (read-only ONLY)
655        assert!(config.is_tool_enabled("inspect"));
656        assert!(config.is_tool_enabled("inspect.styles"));
657        assert!(config.is_tool_enabled("inspect.bounds"));
658        assert!(config.is_tool_enabled("inspect.audit_a11y"));
659        assert!(config.is_tool_enabled("inspect.performance"));
660        // Mutating actions are NOT read-only and must be blocked in Observe.
661        assert!(!config.is_tool_enabled("inspect.highlight"));
662        assert!(!config.is_tool_enabled("inspect.clear_highlights"));
663        assert!(!config.is_tool_enabled("logs.clear"));
664    }
665
666    #[test]
667    fn observe_allows_app_state_probe_reads() {
668        // app_state runs read-only backend probes — pure observation.
669        assert!(observe_privacy_config().is_tool_enabled("app_state"));
670    }
671
672    #[test]
673    fn test_profile_allows_log_clearing_and_highlight() {
674        // The Test profile is for automation: clearing logs and drawing debug
675        // overlays are legitimate test operations (unlike read-only Observe).
676        let config = test_privacy_config();
677        assert!(config.is_tool_enabled("logs.clear"));
678        assert!(config.is_tool_enabled("inspect.highlight"));
679        assert!(config.is_tool_enabled("inspect.clear_highlights"));
680        assert!(config.is_tool_enabled("app_state"));
681    }
682
683    #[test]
684    fn observe_blocks_unlisted_tools() {
685        let config = observe_privacy_config();
686        // Tools not in the closed-world allowlist are blocked
687        assert!(!config.is_tool_enabled("navigate.get_history"));
688        assert!(!config.is_tool_enabled("navigate.get_dialog_log"));
689        assert!(!config.is_tool_enabled("css.get_styles"));
690        assert!(!config.is_tool_enabled("css.get_computed"));
691        assert!(!config.is_tool_enabled("storage.get"));
692        assert!(!config.is_tool_enabled("storage.get_cookies"));
693        assert!(!config.is_tool_enabled("navigate.go_back"));
694    }
695
696    #[test]
697    fn observe_enables_redaction() {
698        let config = observe_privacy_config();
699        assert!(config.redaction_enabled);
700    }
701
702    // ── Explicit disable overrides profile ─────────────────────────────────
703
704    #[test]
705    fn disabled_tools_override_full_control() {
706        let mut disabled = HashSet::new();
707        disabled.insert("eval_js".to_string());
708        let config = PrivacyConfig {
709            profile: PrivacyProfile::FullControl,
710            disabled_tools: disabled,
711            ..Default::default()
712        };
713        assert!(!config.is_tool_enabled("eval_js"));
714        assert!(config.is_tool_enabled("screenshot"));
715    }
716
717    #[test]
718    fn disabled_tools_stack_with_profile() {
719        let mut disabled = HashSet::new();
720        disabled.insert("dom_snapshot".to_string());
721        let mut config = test_privacy_config();
722        config.disabled_tools = disabled;
723        // Profile allows dom_snapshot, but explicit disable overrides
724        assert!(!config.is_tool_enabled("dom_snapshot"));
725        // Profile blocks eval_js
726        assert!(!config.is_tool_enabled("eval_js"));
727    }
728
729    /// R4-NET4: four `inspect` capabilities are named differently from their actions
730    /// (`get_styles` → `inspect.styles`), so `disable_tools(["inspect.get_styles"])` — the
731    /// spelling an operator reads off the tool's action list — was silently a no-op. Both
732    /// spellings must disable the action.
733    #[test]
734    fn inspect_action_spelling_and_capability_id_both_disable() {
735        for (action_spelling, capability) in [
736            ("inspect.get_styles", "inspect.styles"),
737            ("inspect.get_bounding_boxes", "inspect.bounds"),
738            ("inspect.audit_accessibility", "inspect.audit_a11y"),
739            ("inspect.get_performance", "inspect.performance"),
740        ] {
741            for disabled in [action_spelling, capability] {
742                let config = PrivacyConfig {
743                    disabled_tools: HashSet::from([disabled.to_string()]),
744                    ..Default::default()
745                };
746                assert!(
747                    !config.is_call_allowed("inspect", capability),
748                    "disabling {disabled:?} must block {capability}"
749                );
750                assert!(
751                    !config.is_tool_enabled(action_spelling),
752                    "disabling {disabled:?} must block {action_spelling}"
753                );
754            }
755        }
756        // Siblings stay enabled.
757        let config = PrivacyConfig {
758            disabled_tools: HashSet::from(["inspect.get_styles".to_string()]),
759            ..Default::default()
760        };
761        assert!(config.is_call_allowed("inspect", "inspect.bounds"));
762        assert!(config.is_call_allowed("inspect", "inspect.highlight"));
763    }
764
765    // ── invoke_command special handling ─────────────────────────────────────
766
767    #[test]
768    fn invoke_allowed_in_full_control() {
769        let config = PrivacyConfig::default();
770        assert!(config.is_invoke_allowed("any_command"));
771    }
772
773    #[test]
774    fn invoke_blocked_in_observe() {
775        let config = observe_privacy_config();
776        assert!(!config.is_invoke_allowed("any_command"));
777    }
778
779    #[test]
780    fn invoke_allowed_in_test_with_allowlist() {
781        let mut allow = HashSet::new();
782        allow.insert("greet".to_string());
783        let mut config = test_privacy_config();
784        config.command_allowlist = Some(allow);
785        assert!(config.is_invoke_allowed("greet"));
786        assert!(!config.is_invoke_allowed("delete_user"));
787    }
788
789    #[test]
790    fn invoke_blocked_in_test_without_allowlist() {
791        let config = test_privacy_config();
792        assert!(!config.is_invoke_allowed("greet"));
793    }
794
795    // ── strict_privacy_config is Observe ────────────────────────────────────
796
797    #[test]
798    fn strict_privacy_is_observe_profile() {
799        let config = strict_privacy_config();
800        assert_eq!(config.profile, PrivacyProfile::Observe);
801        assert!(config.redaction_enabled);
802    }
803
804    // ── Backward compatibility ──────────────────────────────────────────────
805
806    #[test]
807    fn strict_mode_disables_dangerous_tools() {
808        let config = strict_privacy_config();
809        assert!(!config.is_tool_enabled("eval_js"));
810        assert!(!config.is_tool_enabled("screenshot"));
811        assert!(!config.is_tool_enabled("inject_css"));
812        assert!(!config.is_tool_enabled("navigate"));
813        assert!(!config.is_tool_enabled("invoke_command"));
814        assert!(config.is_tool_enabled("dom_snapshot"));
815        assert!(config.is_tool_enabled("get_memory_stats"));
816        assert!(config.redaction_enabled);
817    }
818
819    #[test]
820    fn strict_mode_blocks_window_mutations() {
821        let config = strict_privacy_config();
822        assert!(!config.is_tool_enabled("window.manage"));
823        assert!(!config.is_tool_enabled("window.resize"));
824        assert!(!config.is_tool_enabled("window.move_to"));
825        assert!(!config.is_tool_enabled("window.set_title"));
826        assert!(config.is_tool_enabled("window"));
827    }
828
829    #[test]
830    fn default_allows_all_actions() {
831        let config = PrivacyConfig::default();
832        assert!(config.is_tool_enabled("invoke_command"));
833        assert!(config.is_tool_enabled("window.manage"));
834        assert!(config.is_tool_enabled("window.resize"));
835        assert!(config.is_tool_enabled("window.move_to"));
836        assert!(config.is_tool_enabled("window.set_title"));
837    }
838
839    // ── Redaction ───────────────────────────────────────────────────────────
840
841    #[test]
842    fn redaction_when_enabled() {
843        let config = PrivacyConfig {
844            redaction_enabled: true,
845            ..Default::default()
846        };
847        let output = config.redact_output("key is sk-abc123def456ghi789jkl012mno");
848        assert!(output.contains("[REDACTED]"));
849    }
850
851    #[test]
852    fn no_redaction_when_disabled() {
853        let config = PrivacyConfig::default();
854        let input = "key is sk-abc123def456ghi789jkl012mno";
855        assert_eq!(config.redact_output(input), input);
856    }
857
858    // ── Display ─────────────────────────────────────────────────────────────
859
860    #[test]
861    fn profile_display() {
862        assert_eq!(PrivacyProfile::Observe.to_string(), "observe");
863        assert_eq!(PrivacyProfile::Test.to_string(), "test");
864        assert_eq!(PrivacyProfile::FullControl.to_string(), "full_control");
865    }
866}