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    /// Check whether `invoke_command` is allowed for a specific command name.
96    ///
97    /// In `Test` profile, `invoke_command` is only allowed if the command is on the
98    /// allowlist. In `FullControl`, it's always allowed. In `Observe`, always blocked.
99    #[must_use]
100    pub fn is_invoke_allowed(&self, command: &str) -> bool {
101        if self.disabled_tools.contains("invoke_command") {
102            return false;
103        }
104        match self.profile {
105            PrivacyProfile::FullControl => true,
106            PrivacyProfile::Test => self
107                .command_allowlist
108                .as_ref()
109                .is_some_and(|al| al.contains(command)),
110            PrivacyProfile::Observe => false,
111        }
112    }
113
114    /// Apply redaction rules to the output string if redaction is enabled.
115    #[must_use]
116    pub fn redact_output(&self, output: &str) -> String {
117        if self.redaction_enabled {
118            self.redactor.redact(output)
119        } else {
120            output.to_string()
121        }
122    }
123}
124
125/// The permission matrix. Maps `(profile, tool_or_action)` → allowed.
126///
127/// Naming convention: standalone tools use bare names (`"eval_js"`), compound tool
128/// actions use dot-qualified names (`"window.manage"`, `"input.fill"`).
129///
130/// Everything not explicitly listed defaults to allowed (open-world for new tools).
131#[must_use]
132fn is_allowed_by_profile(profile: PrivacyProfile, tool_or_action: &str) -> bool {
133    match profile {
134        PrivacyProfile::FullControl => true,
135        PrivacyProfile::Test => matches!(
136            tool_or_action,
137            // Read-only observation (superset of Observe)
138            "dom_snapshot"
139                | "find_elements"
140                | "get_registry"
141                | "get_memory_stats"
142                | "get_plugin_info"
143                | "get_diagnostics"
144                | "detect_ghost_commands"
145                | "check_ipc_integrity"
146                | "resolve_command"
147                | "wait_for"
148                | "app_state"
149                // Assertions (use eval internally but are test-oriented)
150                | "verify_state"
151                | "assert_semantic"
152                // Interactions
153                | "interact"
154                | "interact.click"
155                | "interact.double_click"
156                | "interact.hover"
157                | "interact.focus"
158                | "interact.scroll_into_view"
159                | "interact.select_option"
160                // Input
161                | "fill"
162                | "input"
163                | "input.fill"
164                | "type_text"
165                | "input.type_text"
166                | "input.press_key"
167                // Storage (read + write)
168                | "storage"
169                | "set_storage"
170                | "storage.set"
171                | "delete_storage"
172                | "storage.delete"
173                | "storage.get"
174                | "storage.get_cookies"
175                | "get_storage"
176                | "get_cookies"
177                // Recording
178                | "recording"
179                | "recording.start"
180                | "recording.stop"
181                | "recording.checkpoint"
182                | "recording.list_checkpoints"
183                | "recording.get_events"
184                | "recording.events_between"
185                | "recording.get_replay"
186                | "recording.export"
187                | "recording.import"
188                // Logs (all read-only)
189                | "logs"
190                | "logs.console"
191                | "logs.network"
192                | "logs.ipc"
193                | "logs.navigation"
194                | "logs.dialogs"
195                | "logs.events"
196                | "logs.slow_ipc"
197                // logs.clear erases captured logs — a test-time mutation, allowed
198                // in Test/FullControl but NOT in read-only Observe.
199                | "logs.clear"
200                // Inspect (read-only + visual debug)
201                | "inspect"
202                | "inspect.styles"
203                | "inspect.bounds"
204                | "inspect.highlight"
205                | "inspect.clear_highlights"
206                | "inspect.audit_a11y"
207                | "inspect.performance"
208                // Window (read-only)
209                | "list_windows"
210                | "window"
211                | "window.get_state"
212                | "window.list"
213                | "get_window_state"
214                // Navigate (read-only actions)
215                | "navigate.go_back"
216                | "navigate.get_history"
217                | "navigate.get_dialog_log"
218        ),
219        PrivacyProfile::Observe => matches!(
220            tool_or_action,
221            "dom_snapshot"
222                | "find_elements"
223                | "get_registry"
224                | "get_memory_stats"
225                | "get_plugin_info"
226                | "get_diagnostics"
227                | "detect_ghost_commands"
228                | "check_ipc_integrity"
229                | "resolve_command"
230                | "app_state"
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                // NOTE: logs.clear is intentionally excluded — clearing logs erases
240                // observable evidence, which the read-only Observe profile forbids.
241                | "inspect"
242                | "inspect.styles"
243                | "inspect.bounds"
244                // NOTE: inspect.highlight / clear_highlights are intentionally excluded —
245                // they inject/remove DOM overlay nodes, a mutation Observe forbids.
246                | "inspect.audit_a11y"
247                | "inspect.performance"
248                | "list_windows"
249                | "window"
250                | "window.get_state"
251                | "window.list"
252                | "get_window_state"
253                | "wait_for"
254        ),
255    }
256}
257
258/// Create a [`PrivacyConfig`] for the `Observe` profile with redaction enabled.
259#[must_use]
260pub fn observe_privacy_config() -> PrivacyConfig {
261    PrivacyConfig {
262        profile: PrivacyProfile::Observe,
263        command_allowlist: None,
264        command_blocklist: HashSet::new(),
265        disabled_tools: HashSet::new(),
266        storage_key_blocklist: HashSet::new(),
267        redactor: Redactor::default(),
268        redaction_enabled: true,
269    }
270}
271
272/// Create a [`PrivacyConfig`] for the `Test` profile with redaction enabled.
273#[must_use]
274pub fn test_privacy_config() -> PrivacyConfig {
275    PrivacyConfig {
276        profile: PrivacyProfile::Test,
277        command_allowlist: None,
278        command_blocklist: HashSet::new(),
279        disabled_tools: HashSet::new(),
280        storage_key_blocklist: HashSet::new(),
281        redactor: Redactor::default(),
282        redaction_enabled: true,
283    }
284}
285
286/// Create a [`PrivacyConfig`] that disables dangerous tools and enables redaction.
287///
288/// This is an alias for [`observe_privacy_config()`] — strict mode maps to the
289/// `Observe` profile.
290#[must_use]
291pub fn strict_privacy_config() -> PrivacyConfig {
292    observe_privacy_config()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    // ── Command filtering ──────────────────────────────────────────────────
300
301    #[test]
302    fn storage_key_blocklist_protects_keys() {
303        let mut config = PrivacyConfig::default();
304        // Default: every key is writable.
305        assert!(config.is_storage_key_allowed("auth"));
306        // Once blocked, protected keys are rejected; others still allowed.
307        config.storage_key_blocklist = ["auth", "license_tier"]
308            .iter()
309            .map(ToString::to_string)
310            .collect();
311        assert!(!config.is_storage_key_allowed("auth"));
312        assert!(!config.is_storage_key_allowed("license_tier"));
313        assert!(config.is_storage_key_allowed("theme"));
314    }
315
316    #[test]
317    fn default_allows_all_commands() {
318        let config = PrivacyConfig::default();
319        assert!(config.is_command_allowed("get_settings"));
320        assert!(config.is_command_allowed("anything"));
321    }
322
323    #[test]
324    fn blocklist_blocks() {
325        let mut config = PrivacyConfig::default();
326        config.command_blocklist.insert("save_api_key".to_string());
327        assert!(!config.is_command_allowed("save_api_key"));
328        assert!(config.is_command_allowed("get_settings"));
329    }
330
331    #[test]
332    fn allowlist_restricts() {
333        let mut allow = HashSet::new();
334        allow.insert("get_settings".to_string());
335        allow.insert("get_monitoring_status".to_string());
336        let config = PrivacyConfig {
337            command_allowlist: Some(allow),
338            ..Default::default()
339        };
340        assert!(config.is_command_allowed("get_settings"));
341        assert!(!config.is_command_allowed("save_api_key"));
342    }
343
344    #[test]
345    fn blocklist_wins_over_allowlist() {
346        let mut allow = HashSet::new();
347        allow.insert("save_api_key".to_string());
348        let mut block = HashSet::new();
349        block.insert("save_api_key".to_string());
350        let config = PrivacyConfig {
351            command_allowlist: Some(allow),
352            command_blocklist: block,
353            ..Default::default()
354        };
355        assert!(!config.is_command_allowed("save_api_key"));
356    }
357
358    // ── Profile: FullControl ───────────────────────────────────────────────
359
360    #[test]
361    fn full_control_allows_everything() {
362        let config = PrivacyConfig::default();
363        assert_eq!(config.profile, PrivacyProfile::FullControl);
364        assert!(config.is_tool_enabled("eval_js"));
365        assert!(config.is_tool_enabled("screenshot"));
366        assert!(config.is_tool_enabled("invoke_command"));
367        assert!(config.is_tool_enabled("interact"));
368        assert!(config.is_tool_enabled("interact.click"));
369        assert!(config.is_tool_enabled("input.fill"));
370        assert!(config.is_tool_enabled("window.manage"));
371        assert!(config.is_tool_enabled("navigate"));
372        assert!(config.is_tool_enabled("navigate.go_to"));
373        assert!(config.is_tool_enabled("css.inject"));
374        assert!(config.is_tool_enabled("recording"));
375        assert!(config.is_tool_enabled("storage.set"));
376        assert!(config.is_tool_enabled("set_dialog_response"));
377    }
378
379    // ── Profile: Test ──────────────────────────────────────────────────────
380
381    #[test]
382    fn test_profile_allows_interactions() {
383        let config = test_privacy_config();
384        assert!(config.is_tool_enabled("interact"));
385        assert!(config.is_tool_enabled("interact.click"));
386        assert!(config.is_tool_enabled("interact.double_click"));
387        assert!(config.is_tool_enabled("interact.hover"));
388        assert!(config.is_tool_enabled("interact.focus"));
389        assert!(config.is_tool_enabled("interact.scroll_into_view"));
390        assert!(config.is_tool_enabled("interact.select_option"));
391    }
392
393    #[test]
394    fn test_profile_allows_input() {
395        let config = test_privacy_config();
396        assert!(config.is_tool_enabled("fill"));
397        assert!(config.is_tool_enabled("input.fill"));
398        assert!(config.is_tool_enabled("type_text"));
399        assert!(config.is_tool_enabled("input.type_text"));
400        assert!(config.is_tool_enabled("input.press_key"));
401    }
402
403    #[test]
404    fn test_profile_allows_storage_writes() {
405        let config = test_privacy_config();
406        assert!(config.is_tool_enabled("set_storage"));
407        assert!(config.is_tool_enabled("storage.set"));
408        assert!(config.is_tool_enabled("delete_storage"));
409        assert!(config.is_tool_enabled("storage.delete"));
410    }
411
412    #[test]
413    fn test_profile_allows_recording() {
414        let config = test_privacy_config();
415        assert!(config.is_tool_enabled("recording"));
416        assert!(config.is_tool_enabled("recording.start"));
417        assert!(config.is_tool_enabled("recording.stop"));
418    }
419
420    #[test]
421    fn test_profile_blocks_eval_and_screenshot() {
422        let config = test_privacy_config();
423        assert!(!config.is_tool_enabled("eval_js"));
424        assert!(!config.is_tool_enabled("screenshot"));
425    }
426
427    #[test]
428    fn test_profile_blocks_navigation() {
429        let config = test_privacy_config();
430        assert!(!config.is_tool_enabled("navigate"));
431        assert!(!config.is_tool_enabled("navigate.go_to"));
432        assert!(!config.is_tool_enabled("set_dialog_response"));
433        assert!(!config.is_tool_enabled("navigate.set_dialog_response"));
434    }
435
436    #[test]
437    fn test_profile_blocks_window_mutations() {
438        let config = test_privacy_config();
439        assert!(!config.is_tool_enabled("window.manage"));
440        assert!(!config.is_tool_enabled("window.resize"));
441        assert!(!config.is_tool_enabled("window.move_to"));
442        assert!(!config.is_tool_enabled("window.set_title"));
443    }
444
445    #[test]
446    fn test_profile_blocks_css_injection() {
447        let config = test_privacy_config();
448        assert!(!config.is_tool_enabled("inject_css"));
449        assert!(!config.is_tool_enabled("css.inject"));
450        assert!(!config.is_tool_enabled("css.remove"));
451    }
452
453    #[test]
454    fn test_profile_blocks_invoke_command() {
455        let config = test_privacy_config();
456        assert!(!config.is_tool_enabled("invoke_command"));
457    }
458
459    #[test]
460    fn test_profile_allows_read_only_tools() {
461        let config = test_privacy_config();
462        assert!(config.is_tool_enabled("dom_snapshot"));
463        assert!(config.is_tool_enabled("find_elements"));
464        assert!(config.is_tool_enabled("verify_state"));
465        assert!(config.is_tool_enabled("detect_ghost_commands"));
466        assert!(config.is_tool_enabled("check_ipc_integrity"));
467        assert!(config.is_tool_enabled("get_registry"));
468        assert!(config.is_tool_enabled("get_memory_stats"));
469        assert!(config.is_tool_enabled("get_plugin_info"));
470        assert!(config.is_tool_enabled("resolve_command"));
471        assert!(config.is_tool_enabled("wait_for"));
472        assert!(config.is_tool_enabled("assert_semantic"));
473    }
474
475    // ── Profile: Observe ───────────────────────────────────────────────────
476
477    #[test]
478    fn observe_blocks_all_interactions() {
479        let config = observe_privacy_config();
480        assert!(!config.is_tool_enabled("interact"));
481        assert!(!config.is_tool_enabled("interact.click"));
482        assert!(!config.is_tool_enabled("interact.double_click"));
483        assert!(!config.is_tool_enabled("interact.hover"));
484        assert!(!config.is_tool_enabled("interact.focus"));
485        assert!(!config.is_tool_enabled("interact.scroll_into_view"));
486        assert!(!config.is_tool_enabled("interact.select_option"));
487    }
488
489    #[test]
490    fn observe_blocks_all_input() {
491        let config = observe_privacy_config();
492        assert!(!config.is_tool_enabled("fill"));
493        assert!(!config.is_tool_enabled("input.fill"));
494        assert!(!config.is_tool_enabled("type_text"));
495        assert!(!config.is_tool_enabled("input.type_text"));
496        assert!(!config.is_tool_enabled("input.press_key"));
497    }
498
499    #[test]
500    fn observe_blocks_storage_writes() {
501        let config = observe_privacy_config();
502        assert!(!config.is_tool_enabled("set_storage"));
503        assert!(!config.is_tool_enabled("storage.set"));
504        assert!(!config.is_tool_enabled("delete_storage"));
505        assert!(!config.is_tool_enabled("storage.delete"));
506    }
507
508    #[test]
509    fn observe_blocks_recording() {
510        let config = observe_privacy_config();
511        assert!(!config.is_tool_enabled("recording"));
512        assert!(!config.is_tool_enabled("recording.start"));
513        assert!(!config.is_tool_enabled("recording.stop"));
514    }
515
516    #[test]
517    fn observe_blocks_dangerous_tools() {
518        let config = observe_privacy_config();
519        assert!(!config.is_tool_enabled("eval_js"));
520        assert!(!config.is_tool_enabled("screenshot"));
521        assert!(!config.is_tool_enabled("invoke_command"));
522        assert!(!config.is_tool_enabled("navigate"));
523        assert!(!config.is_tool_enabled("navigate.go_to"));
524        assert!(!config.is_tool_enabled("inject_css"));
525        assert!(!config.is_tool_enabled("css.inject"));
526        assert!(!config.is_tool_enabled("css.remove"));
527        assert!(!config.is_tool_enabled("window.manage"));
528        assert!(!config.is_tool_enabled("window.resize"));
529        assert!(!config.is_tool_enabled("window.move_to"));
530        assert!(!config.is_tool_enabled("window.set_title"));
531    }
532
533    #[test]
534    fn observe_allows_read_only_tools() {
535        let config = observe_privacy_config();
536        assert!(config.is_tool_enabled("dom_snapshot"));
537        assert!(config.is_tool_enabled("find_elements"));
538        assert!(config.is_tool_enabled("detect_ghost_commands"));
539        assert!(config.is_tool_enabled("check_ipc_integrity"));
540        assert!(config.is_tool_enabled("get_registry"));
541        assert!(config.is_tool_enabled("get_memory_stats"));
542        assert!(config.is_tool_enabled("get_plugin_info"));
543        assert!(config.is_tool_enabled("get_diagnostics"));
544        assert!(config.is_tool_enabled("resolve_command"));
545        assert!(config.is_tool_enabled("wait_for"));
546        assert!(config.is_tool_enabled("window")); // the compound tool itself (get_state, list)
547    }
548
549    #[test]
550    fn observe_blocks_eval_dependent_tools() {
551        let config = observe_privacy_config();
552        // verify_state and assert_semantic depend on eval_js internally,
553        // so they are excluded from the Observe allowlist.
554        assert!(!config.is_tool_enabled("verify_state"));
555        assert!(!config.is_tool_enabled("assert_semantic"));
556    }
557
558    #[test]
559    fn observe_allows_read_actions_on_compound_tools() {
560        let config = observe_privacy_config();
561        // Window read actions
562        assert!(config.is_tool_enabled("window.get_state"));
563        assert!(config.is_tool_enabled("window.list"));
564        // Logs (all read-only)
565        assert!(config.is_tool_enabled("logs"));
566        assert!(config.is_tool_enabled("logs.console"));
567        assert!(config.is_tool_enabled("logs.network"));
568        assert!(config.is_tool_enabled("logs.ipc"));
569        assert!(config.is_tool_enabled("logs.navigation"));
570        assert!(config.is_tool_enabled("logs.dialogs"));
571        assert!(config.is_tool_enabled("logs.events"));
572        assert!(config.is_tool_enabled("logs.slow_ipc"));
573        // Inspect (read-only ONLY)
574        assert!(config.is_tool_enabled("inspect"));
575        assert!(config.is_tool_enabled("inspect.styles"));
576        assert!(config.is_tool_enabled("inspect.bounds"));
577        assert!(config.is_tool_enabled("inspect.audit_a11y"));
578        assert!(config.is_tool_enabled("inspect.performance"));
579        // Mutating actions are NOT read-only and must be blocked in Observe.
580        assert!(!config.is_tool_enabled("inspect.highlight"));
581        assert!(!config.is_tool_enabled("inspect.clear_highlights"));
582        assert!(!config.is_tool_enabled("logs.clear"));
583    }
584
585    #[test]
586    fn observe_allows_app_state_probe_reads() {
587        // app_state runs read-only backend probes — pure observation.
588        assert!(observe_privacy_config().is_tool_enabled("app_state"));
589    }
590
591    #[test]
592    fn test_profile_allows_log_clearing_and_highlight() {
593        // The Test profile is for automation: clearing logs and drawing debug
594        // overlays are legitimate test operations (unlike read-only Observe).
595        let config = test_privacy_config();
596        assert!(config.is_tool_enabled("logs.clear"));
597        assert!(config.is_tool_enabled("inspect.highlight"));
598        assert!(config.is_tool_enabled("inspect.clear_highlights"));
599        assert!(config.is_tool_enabled("app_state"));
600    }
601
602    #[test]
603    fn observe_blocks_unlisted_tools() {
604        let config = observe_privacy_config();
605        // Tools not in the closed-world allowlist are blocked
606        assert!(!config.is_tool_enabled("navigate.get_history"));
607        assert!(!config.is_tool_enabled("navigate.get_dialog_log"));
608        assert!(!config.is_tool_enabled("css.get_styles"));
609        assert!(!config.is_tool_enabled("css.get_computed"));
610        assert!(!config.is_tool_enabled("storage.get"));
611        assert!(!config.is_tool_enabled("storage.get_cookies"));
612        assert!(!config.is_tool_enabled("navigate.go_back"));
613    }
614
615    #[test]
616    fn observe_enables_redaction() {
617        let config = observe_privacy_config();
618        assert!(config.redaction_enabled);
619    }
620
621    // ── Explicit disable overrides profile ─────────────────────────────────
622
623    #[test]
624    fn disabled_tools_override_full_control() {
625        let mut disabled = HashSet::new();
626        disabled.insert("eval_js".to_string());
627        let config = PrivacyConfig {
628            profile: PrivacyProfile::FullControl,
629            disabled_tools: disabled,
630            ..Default::default()
631        };
632        assert!(!config.is_tool_enabled("eval_js"));
633        assert!(config.is_tool_enabled("screenshot"));
634    }
635
636    #[test]
637    fn disabled_tools_stack_with_profile() {
638        let mut disabled = HashSet::new();
639        disabled.insert("dom_snapshot".to_string());
640        let mut config = test_privacy_config();
641        config.disabled_tools = disabled;
642        // Profile allows dom_snapshot, but explicit disable overrides
643        assert!(!config.is_tool_enabled("dom_snapshot"));
644        // Profile blocks eval_js
645        assert!(!config.is_tool_enabled("eval_js"));
646    }
647
648    // ── invoke_command special handling ─────────────────────────────────────
649
650    #[test]
651    fn invoke_allowed_in_full_control() {
652        let config = PrivacyConfig::default();
653        assert!(config.is_invoke_allowed("any_command"));
654    }
655
656    #[test]
657    fn invoke_blocked_in_observe() {
658        let config = observe_privacy_config();
659        assert!(!config.is_invoke_allowed("any_command"));
660    }
661
662    #[test]
663    fn invoke_allowed_in_test_with_allowlist() {
664        let mut allow = HashSet::new();
665        allow.insert("greet".to_string());
666        let mut config = test_privacy_config();
667        config.command_allowlist = Some(allow);
668        assert!(config.is_invoke_allowed("greet"));
669        assert!(!config.is_invoke_allowed("delete_user"));
670    }
671
672    #[test]
673    fn invoke_blocked_in_test_without_allowlist() {
674        let config = test_privacy_config();
675        assert!(!config.is_invoke_allowed("greet"));
676    }
677
678    // ── strict_privacy_config is Observe ────────────────────────────────────
679
680    #[test]
681    fn strict_privacy_is_observe_profile() {
682        let config = strict_privacy_config();
683        assert_eq!(config.profile, PrivacyProfile::Observe);
684        assert!(config.redaction_enabled);
685    }
686
687    // ── Backward compatibility ──────────────────────────────────────────────
688
689    #[test]
690    fn strict_mode_disables_dangerous_tools() {
691        let config = strict_privacy_config();
692        assert!(!config.is_tool_enabled("eval_js"));
693        assert!(!config.is_tool_enabled("screenshot"));
694        assert!(!config.is_tool_enabled("inject_css"));
695        assert!(!config.is_tool_enabled("navigate"));
696        assert!(!config.is_tool_enabled("invoke_command"));
697        assert!(config.is_tool_enabled("dom_snapshot"));
698        assert!(config.is_tool_enabled("get_memory_stats"));
699        assert!(config.redaction_enabled);
700    }
701
702    #[test]
703    fn strict_mode_blocks_window_mutations() {
704        let config = strict_privacy_config();
705        assert!(!config.is_tool_enabled("window.manage"));
706        assert!(!config.is_tool_enabled("window.resize"));
707        assert!(!config.is_tool_enabled("window.move_to"));
708        assert!(!config.is_tool_enabled("window.set_title"));
709        assert!(config.is_tool_enabled("window"));
710    }
711
712    #[test]
713    fn default_allows_all_actions() {
714        let config = PrivacyConfig::default();
715        assert!(config.is_tool_enabled("invoke_command"));
716        assert!(config.is_tool_enabled("window.manage"));
717        assert!(config.is_tool_enabled("window.resize"));
718        assert!(config.is_tool_enabled("window.move_to"));
719        assert!(config.is_tool_enabled("window.set_title"));
720    }
721
722    // ── Redaction ───────────────────────────────────────────────────────────
723
724    #[test]
725    fn redaction_when_enabled() {
726        let config = PrivacyConfig {
727            redaction_enabled: true,
728            ..Default::default()
729        };
730        let output = config.redact_output("key is sk-abc123def456ghi789jkl012mno");
731        assert!(output.contains("[REDACTED]"));
732    }
733
734    #[test]
735    fn no_redaction_when_disabled() {
736        let config = PrivacyConfig::default();
737        let input = "key is sk-abc123def456ghi789jkl012mno";
738        assert_eq!(config.redact_output(input), input);
739    }
740
741    // ── Display ─────────────────────────────────────────────────────────────
742
743    #[test]
744    fn profile_display() {
745        assert_eq!(PrivacyProfile::Observe.to_string(), "observe");
746        assert_eq!(PrivacyProfile::Test.to_string(), "test");
747        assert_eq!(PrivacyProfile::FullControl.to_string(), "full_control");
748    }
749}