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