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