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