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