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            "eval_js"
127                | "screenshot"
128                | "invoke_command"
129                | "navigate"
130                | "navigate.go_to"
131                | "set_dialog_response"
132                | "navigate.set_dialog_response"
133                | "inject_css"
134                | "css.inject"
135                | "css.remove"
136                | "window.manage"
137                | "window.resize"
138                | "window.move_to"
139                | "window.set_title"
140        ),
141        PrivacyProfile::Observe => matches!(
142            tool_or_action,
143            "dom_snapshot"
144                | "find_elements"
145                | "get_registry"
146                | "get_memory_stats"
147                | "get_plugin_info"
148                | "detect_ghost_commands"
149                | "check_ipc_integrity"
150                | "resolve_command"
151                | "logs"
152                | "logs.console"
153                | "logs.network"
154                | "logs.ipc"
155                | "logs.navigation"
156                | "logs.dialogs"
157                | "logs.events"
158                | "logs.slow_ipc"
159                | "inspect"
160                | "inspect.styles"
161                | "inspect.bounds"
162                | "inspect.highlight"
163                | "inspect.clear_highlights"
164                | "inspect.audit_a11y"
165                | "inspect.performance"
166                | "list_windows"
167                | "window"
168                | "window.get_state"
169                | "window.list"
170                | "get_window_state"
171                | "wait_for"
172        ),
173    }
174}
175
176/// Create a [`PrivacyConfig`] for the `Observe` profile with redaction enabled.
177#[must_use]
178pub fn observe_privacy_config() -> PrivacyConfig {
179    PrivacyConfig {
180        profile: PrivacyProfile::Observe,
181        command_allowlist: None,
182        command_blocklist: HashSet::new(),
183        disabled_tools: HashSet::new(),
184        redactor: Redactor::default(),
185        redaction_enabled: true,
186    }
187}
188
189/// Create a [`PrivacyConfig`] for the `Test` profile with redaction enabled.
190#[must_use]
191pub fn test_privacy_config() -> PrivacyConfig {
192    PrivacyConfig {
193        profile: PrivacyProfile::Test,
194        command_allowlist: None,
195        command_blocklist: HashSet::new(),
196        disabled_tools: HashSet::new(),
197        redactor: Redactor::default(),
198        redaction_enabled: true,
199    }
200}
201
202/// Create a [`PrivacyConfig`] that disables dangerous tools and enables redaction.
203///
204/// This is an alias for [`observe_privacy_config()`] — strict mode maps to the
205/// `Observe` profile.
206#[must_use]
207pub fn strict_privacy_config() -> PrivacyConfig {
208    observe_privacy_config()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    // ── Command filtering ──────────────────────────────────────────────────
216
217    #[test]
218    fn default_allows_all_commands() {
219        let config = PrivacyConfig::default();
220        assert!(config.is_command_allowed("get_settings"));
221        assert!(config.is_command_allowed("anything"));
222    }
223
224    #[test]
225    fn blocklist_blocks() {
226        let mut config = PrivacyConfig::default();
227        config.command_blocklist.insert("save_api_key".to_string());
228        assert!(!config.is_command_allowed("save_api_key"));
229        assert!(config.is_command_allowed("get_settings"));
230    }
231
232    #[test]
233    fn allowlist_restricts() {
234        let mut allow = HashSet::new();
235        allow.insert("get_settings".to_string());
236        allow.insert("get_monitoring_status".to_string());
237        let config = PrivacyConfig {
238            command_allowlist: Some(allow),
239            ..Default::default()
240        };
241        assert!(config.is_command_allowed("get_settings"));
242        assert!(!config.is_command_allowed("save_api_key"));
243    }
244
245    #[test]
246    fn blocklist_wins_over_allowlist() {
247        let mut allow = HashSet::new();
248        allow.insert("save_api_key".to_string());
249        let mut block = HashSet::new();
250        block.insert("save_api_key".to_string());
251        let config = PrivacyConfig {
252            command_allowlist: Some(allow),
253            command_blocklist: block,
254            ..Default::default()
255        };
256        assert!(!config.is_command_allowed("save_api_key"));
257    }
258
259    // ── Profile: FullControl ───────────────────────────────────────────────
260
261    #[test]
262    fn full_control_allows_everything() {
263        let config = PrivacyConfig::default();
264        assert_eq!(config.profile, PrivacyProfile::FullControl);
265        assert!(config.is_tool_enabled("eval_js"));
266        assert!(config.is_tool_enabled("screenshot"));
267        assert!(config.is_tool_enabled("invoke_command"));
268        assert!(config.is_tool_enabled("interact"));
269        assert!(config.is_tool_enabled("interact.click"));
270        assert!(config.is_tool_enabled("input.fill"));
271        assert!(config.is_tool_enabled("window.manage"));
272        assert!(config.is_tool_enabled("navigate"));
273        assert!(config.is_tool_enabled("navigate.go_to"));
274        assert!(config.is_tool_enabled("css.inject"));
275        assert!(config.is_tool_enabled("recording"));
276        assert!(config.is_tool_enabled("storage.set"));
277        assert!(config.is_tool_enabled("set_dialog_response"));
278    }
279
280    // ── Profile: Test ──────────────────────────────────────────────────────
281
282    #[test]
283    fn test_profile_allows_interactions() {
284        let config = test_privacy_config();
285        assert!(config.is_tool_enabled("interact"));
286        assert!(config.is_tool_enabled("interact.click"));
287        assert!(config.is_tool_enabled("interact.double_click"));
288        assert!(config.is_tool_enabled("interact.hover"));
289        assert!(config.is_tool_enabled("interact.focus"));
290        assert!(config.is_tool_enabled("interact.scroll_into_view"));
291        assert!(config.is_tool_enabled("interact.select_option"));
292    }
293
294    #[test]
295    fn test_profile_allows_input() {
296        let config = test_privacy_config();
297        assert!(config.is_tool_enabled("fill"));
298        assert!(config.is_tool_enabled("input.fill"));
299        assert!(config.is_tool_enabled("type_text"));
300        assert!(config.is_tool_enabled("input.type_text"));
301        assert!(config.is_tool_enabled("input.press_key"));
302    }
303
304    #[test]
305    fn test_profile_allows_storage_writes() {
306        let config = test_privacy_config();
307        assert!(config.is_tool_enabled("set_storage"));
308        assert!(config.is_tool_enabled("storage.set"));
309        assert!(config.is_tool_enabled("delete_storage"));
310        assert!(config.is_tool_enabled("storage.delete"));
311    }
312
313    #[test]
314    fn test_profile_allows_recording() {
315        let config = test_privacy_config();
316        assert!(config.is_tool_enabled("recording"));
317        assert!(config.is_tool_enabled("recording.start"));
318        assert!(config.is_tool_enabled("recording.stop"));
319    }
320
321    #[test]
322    fn test_profile_blocks_eval_and_screenshot() {
323        let config = test_privacy_config();
324        assert!(!config.is_tool_enabled("eval_js"));
325        assert!(!config.is_tool_enabled("screenshot"));
326    }
327
328    #[test]
329    fn test_profile_blocks_navigation() {
330        let config = test_privacy_config();
331        assert!(!config.is_tool_enabled("navigate"));
332        assert!(!config.is_tool_enabled("navigate.go_to"));
333        assert!(!config.is_tool_enabled("set_dialog_response"));
334        assert!(!config.is_tool_enabled("navigate.set_dialog_response"));
335    }
336
337    #[test]
338    fn test_profile_blocks_window_mutations() {
339        let config = test_privacy_config();
340        assert!(!config.is_tool_enabled("window.manage"));
341        assert!(!config.is_tool_enabled("window.resize"));
342        assert!(!config.is_tool_enabled("window.move_to"));
343        assert!(!config.is_tool_enabled("window.set_title"));
344    }
345
346    #[test]
347    fn test_profile_blocks_css_injection() {
348        let config = test_privacy_config();
349        assert!(!config.is_tool_enabled("inject_css"));
350        assert!(!config.is_tool_enabled("css.inject"));
351        assert!(!config.is_tool_enabled("css.remove"));
352    }
353
354    #[test]
355    fn test_profile_blocks_invoke_command() {
356        let config = test_privacy_config();
357        assert!(!config.is_tool_enabled("invoke_command"));
358    }
359
360    #[test]
361    fn test_profile_allows_read_only_tools() {
362        let config = test_privacy_config();
363        assert!(config.is_tool_enabled("dom_snapshot"));
364        assert!(config.is_tool_enabled("find_elements"));
365        assert!(config.is_tool_enabled("verify_state"));
366        assert!(config.is_tool_enabled("detect_ghost_commands"));
367        assert!(config.is_tool_enabled("check_ipc_integrity"));
368        assert!(config.is_tool_enabled("get_registry"));
369        assert!(config.is_tool_enabled("get_memory_stats"));
370        assert!(config.is_tool_enabled("get_plugin_info"));
371        assert!(config.is_tool_enabled("resolve_command"));
372        assert!(config.is_tool_enabled("wait_for"));
373        assert!(config.is_tool_enabled("assert_semantic"));
374    }
375
376    // ── Profile: Observe ───────────────────────────────────────────────────
377
378    #[test]
379    fn observe_blocks_all_interactions() {
380        let config = observe_privacy_config();
381        assert!(!config.is_tool_enabled("interact"));
382        assert!(!config.is_tool_enabled("interact.click"));
383        assert!(!config.is_tool_enabled("interact.double_click"));
384        assert!(!config.is_tool_enabled("interact.hover"));
385        assert!(!config.is_tool_enabled("interact.focus"));
386        assert!(!config.is_tool_enabled("interact.scroll_into_view"));
387        assert!(!config.is_tool_enabled("interact.select_option"));
388    }
389
390    #[test]
391    fn observe_blocks_all_input() {
392        let config = observe_privacy_config();
393        assert!(!config.is_tool_enabled("fill"));
394        assert!(!config.is_tool_enabled("input.fill"));
395        assert!(!config.is_tool_enabled("type_text"));
396        assert!(!config.is_tool_enabled("input.type_text"));
397        assert!(!config.is_tool_enabled("input.press_key"));
398    }
399
400    #[test]
401    fn observe_blocks_storage_writes() {
402        let config = observe_privacy_config();
403        assert!(!config.is_tool_enabled("set_storage"));
404        assert!(!config.is_tool_enabled("storage.set"));
405        assert!(!config.is_tool_enabled("delete_storage"));
406        assert!(!config.is_tool_enabled("storage.delete"));
407    }
408
409    #[test]
410    fn observe_blocks_recording() {
411        let config = observe_privacy_config();
412        assert!(!config.is_tool_enabled("recording"));
413        assert!(!config.is_tool_enabled("recording.start"));
414        assert!(!config.is_tool_enabled("recording.stop"));
415    }
416
417    #[test]
418    fn observe_blocks_dangerous_tools() {
419        let config = observe_privacy_config();
420        assert!(!config.is_tool_enabled("eval_js"));
421        assert!(!config.is_tool_enabled("screenshot"));
422        assert!(!config.is_tool_enabled("invoke_command"));
423        assert!(!config.is_tool_enabled("navigate"));
424        assert!(!config.is_tool_enabled("navigate.go_to"));
425        assert!(!config.is_tool_enabled("inject_css"));
426        assert!(!config.is_tool_enabled("css.inject"));
427        assert!(!config.is_tool_enabled("css.remove"));
428        assert!(!config.is_tool_enabled("window.manage"));
429        assert!(!config.is_tool_enabled("window.resize"));
430        assert!(!config.is_tool_enabled("window.move_to"));
431        assert!(!config.is_tool_enabled("window.set_title"));
432    }
433
434    #[test]
435    fn observe_allows_read_only_tools() {
436        let config = observe_privacy_config();
437        assert!(config.is_tool_enabled("dom_snapshot"));
438        assert!(config.is_tool_enabled("find_elements"));
439        assert!(config.is_tool_enabled("detect_ghost_commands"));
440        assert!(config.is_tool_enabled("check_ipc_integrity"));
441        assert!(config.is_tool_enabled("get_registry"));
442        assert!(config.is_tool_enabled("get_memory_stats"));
443        assert!(config.is_tool_enabled("get_plugin_info"));
444        assert!(config.is_tool_enabled("resolve_command"));
445        assert!(config.is_tool_enabled("wait_for"));
446        assert!(config.is_tool_enabled("window")); // the compound tool itself (get_state, list)
447    }
448
449    #[test]
450    fn observe_blocks_eval_dependent_tools() {
451        let config = observe_privacy_config();
452        // verify_state and assert_semantic depend on eval_js internally,
453        // so they are excluded from the Observe allowlist.
454        assert!(!config.is_tool_enabled("verify_state"));
455        assert!(!config.is_tool_enabled("assert_semantic"));
456    }
457
458    #[test]
459    fn observe_allows_read_actions_on_compound_tools() {
460        let config = observe_privacy_config();
461        // Window read actions
462        assert!(config.is_tool_enabled("window.get_state"));
463        assert!(config.is_tool_enabled("window.list"));
464        // Logs (all read-only)
465        assert!(config.is_tool_enabled("logs"));
466        assert!(config.is_tool_enabled("logs.console"));
467        assert!(config.is_tool_enabled("logs.network"));
468        assert!(config.is_tool_enabled("logs.ipc"));
469        assert!(config.is_tool_enabled("logs.navigation"));
470        assert!(config.is_tool_enabled("logs.dialogs"));
471        assert!(config.is_tool_enabled("logs.events"));
472        assert!(config.is_tool_enabled("logs.slow_ipc"));
473        // Inspect (read-only)
474        assert!(config.is_tool_enabled("inspect"));
475        assert!(config.is_tool_enabled("inspect.styles"));
476        assert!(config.is_tool_enabled("inspect.bounds"));
477        assert!(config.is_tool_enabled("inspect.highlight"));
478        assert!(config.is_tool_enabled("inspect.clear_highlights"));
479        assert!(config.is_tool_enabled("inspect.audit_a11y"));
480        assert!(config.is_tool_enabled("inspect.performance"));
481    }
482
483    #[test]
484    fn observe_blocks_unlisted_tools() {
485        let config = observe_privacy_config();
486        // Tools not in the closed-world allowlist are blocked
487        assert!(!config.is_tool_enabled("navigate.get_history"));
488        assert!(!config.is_tool_enabled("navigate.get_dialog_log"));
489        assert!(!config.is_tool_enabled("css.get_styles"));
490        assert!(!config.is_tool_enabled("css.get_computed"));
491        assert!(!config.is_tool_enabled("storage.get"));
492        assert!(!config.is_tool_enabled("storage.get_cookies"));
493        assert!(!config.is_tool_enabled("navigate.go_back"));
494    }
495
496    #[test]
497    fn observe_enables_redaction() {
498        let config = observe_privacy_config();
499        assert!(config.redaction_enabled);
500    }
501
502    // ── Explicit disable overrides profile ─────────────────────────────────
503
504    #[test]
505    fn disabled_tools_override_full_control() {
506        let mut disabled = HashSet::new();
507        disabled.insert("eval_js".to_string());
508        let config = PrivacyConfig {
509            profile: PrivacyProfile::FullControl,
510            disabled_tools: disabled,
511            ..Default::default()
512        };
513        assert!(!config.is_tool_enabled("eval_js"));
514        assert!(config.is_tool_enabled("screenshot"));
515    }
516
517    #[test]
518    fn disabled_tools_stack_with_profile() {
519        let mut disabled = HashSet::new();
520        disabled.insert("dom_snapshot".to_string());
521        let mut config = test_privacy_config();
522        config.disabled_tools = disabled;
523        // Profile allows dom_snapshot, but explicit disable overrides
524        assert!(!config.is_tool_enabled("dom_snapshot"));
525        // Profile blocks eval_js
526        assert!(!config.is_tool_enabled("eval_js"));
527    }
528
529    // ── invoke_command special handling ─────────────────────────────────────
530
531    #[test]
532    fn invoke_allowed_in_full_control() {
533        let config = PrivacyConfig::default();
534        assert!(config.is_invoke_allowed("any_command"));
535    }
536
537    #[test]
538    fn invoke_blocked_in_observe() {
539        let config = observe_privacy_config();
540        assert!(!config.is_invoke_allowed("any_command"));
541    }
542
543    #[test]
544    fn invoke_allowed_in_test_with_allowlist() {
545        let mut allow = HashSet::new();
546        allow.insert("greet".to_string());
547        let mut config = test_privacy_config();
548        config.command_allowlist = Some(allow);
549        assert!(config.is_invoke_allowed("greet"));
550        assert!(!config.is_invoke_allowed("delete_user"));
551    }
552
553    #[test]
554    fn invoke_blocked_in_test_without_allowlist() {
555        let config = test_privacy_config();
556        assert!(!config.is_invoke_allowed("greet"));
557    }
558
559    // ── strict_privacy_config is Observe ────────────────────────────────────
560
561    #[test]
562    fn strict_privacy_is_observe_profile() {
563        let config = strict_privacy_config();
564        assert_eq!(config.profile, PrivacyProfile::Observe);
565        assert!(config.redaction_enabled);
566    }
567
568    // ── Backward compatibility ──────────────────────────────────────────────
569
570    #[test]
571    fn strict_mode_disables_dangerous_tools() {
572        let config = strict_privacy_config();
573        assert!(!config.is_tool_enabled("eval_js"));
574        assert!(!config.is_tool_enabled("screenshot"));
575        assert!(!config.is_tool_enabled("inject_css"));
576        assert!(!config.is_tool_enabled("navigate"));
577        assert!(!config.is_tool_enabled("invoke_command"));
578        assert!(config.is_tool_enabled("dom_snapshot"));
579        assert!(config.is_tool_enabled("get_memory_stats"));
580        assert!(config.redaction_enabled);
581    }
582
583    #[test]
584    fn strict_mode_blocks_window_mutations() {
585        let config = strict_privacy_config();
586        assert!(!config.is_tool_enabled("window.manage"));
587        assert!(!config.is_tool_enabled("window.resize"));
588        assert!(!config.is_tool_enabled("window.move_to"));
589        assert!(!config.is_tool_enabled("window.set_title"));
590        assert!(config.is_tool_enabled("window"));
591    }
592
593    #[test]
594    fn default_allows_all_actions() {
595        let config = PrivacyConfig::default();
596        assert!(config.is_tool_enabled("invoke_command"));
597        assert!(config.is_tool_enabled("window.manage"));
598        assert!(config.is_tool_enabled("window.resize"));
599        assert!(config.is_tool_enabled("window.move_to"));
600        assert!(config.is_tool_enabled("window.set_title"));
601    }
602
603    // ── Redaction ───────────────────────────────────────────────────────────
604
605    #[test]
606    fn redaction_when_enabled() {
607        let config = PrivacyConfig {
608            redaction_enabled: true,
609            ..Default::default()
610        };
611        let output = config.redact_output("key is sk-abc123def456ghi789jkl012mno");
612        assert!(output.contains("[REDACTED]"));
613    }
614
615    #[test]
616    fn no_redaction_when_disabled() {
617        let config = PrivacyConfig::default();
618        let input = "key is sk-abc123def456ghi789jkl012mno";
619        assert_eq!(config.redact_output(input), input);
620    }
621
622    // ── Display ─────────────────────────────────────────────────────────────
623
624    #[test]
625    fn profile_display() {
626        assert_eq!(PrivacyProfile::Observe.to_string(), "observe");
627        assert_eq!(PrivacyProfile::Test.to_string(), "test");
628        assert_eq!(PrivacyProfile::FullControl.to_string(), "full_control");
629    }
630}