Skip to main content

zeph_tools/
trust_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Trust-level enforcement layer for tool execution.
5
6use std::collections::HashSet;
7use std::sync::{
8    Arc, RwLock,
9    atomic::{AtomicU8, Ordering},
10};
11
12use crate::TrustLevel;
13
14use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
15use crate::permissions::{AutonomyLevel, PermissionAction, PermissionPolicy};
16use crate::registry::ToolDef;
17
18/// Tools denied when a Quarantined skill is active.
19///
20/// Uses the actual tool IDs registered by `FileExecutor` and other executors.
21/// Previously contained `"file_write"` which matched nothing (dead rule).
22///
23/// MCP tools use a server-prefixed ID (e.g. `filesystem_write_file`). The
24/// `is_quarantine_denied` predicate checks both exact matches and `_{entry}`
25/// suffix matches to cover MCP-wrapped versions of these native tool IDs.
26/// False positives (a safe tool whose name ends with a denied suffix) are
27/// acceptable at the Quarantined trust level.
28///
29/// Public so that `zeph-skills::scanner::check_capability_escalation` can use
30/// this as the single source of truth for quarantine-denied tools.
31pub const QUARANTINE_DENIED: &[&str] = &[
32    // Shell execution
33    "bash",
34    // File write/mutation tools (FileExecutor IDs)
35    "write",
36    "edit",
37    "delete_path",
38    "move_path",
39    "copy_path",
40    "create_directory",
41    // Web access
42    "web_scrape",
43    "fetch",
44    // Memory persistence
45    "memory_save",
46];
47
48fn is_quarantine_denied(tool_id: &str) -> bool {
49    QUARANTINE_DENIED
50        .iter()
51        .any(|denied| tool_id == *denied || tool_id.ends_with(&format!("_{denied}")))
52}
53
54fn trust_to_u8(level: TrustLevel) -> u8 {
55    match level {
56        TrustLevel::Trusted => 0,
57        TrustLevel::Verified => 1,
58        TrustLevel::Quarantined => 2,
59        TrustLevel::Blocked => 3,
60    }
61}
62
63fn u8_to_trust(v: u8) -> TrustLevel {
64    match v {
65        0 => TrustLevel::Trusted,
66        1 => TrustLevel::Verified,
67        2 => TrustLevel::Quarantined,
68        _ => TrustLevel::Blocked,
69    }
70}
71
72/// Wraps an inner `ToolExecutor` and applies trust-level permission overlays.
73pub struct TrustGateExecutor<T: ToolExecutor> {
74    inner: T,
75    policy: PermissionPolicy,
76    effective_trust: AtomicU8,
77    /// Sanitized IDs of all registered MCP tools. When a Quarantined skill is
78    /// active, any tool whose ID appears in this set is denied — regardless of
79    /// whether its name matches `QUARANTINE_DENIED`. Populated at startup by
80    /// calling `set_mcp_tool_ids` after MCP servers connect.
81    mcp_tool_ids: Arc<RwLock<HashSet<String>>>,
82}
83
84impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for TrustGateExecutor<T> {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("TrustGateExecutor")
87            .field("inner", &self.inner)
88            .field("policy", &self.policy)
89            .field("effective_trust", &self.effective_trust())
90            .field("mcp_tool_ids", &self.mcp_tool_ids)
91            .finish()
92    }
93}
94
95impl<T: ToolExecutor> TrustGateExecutor<T> {
96    #[must_use]
97    pub fn new(inner: T, policy: PermissionPolicy) -> Self {
98        Self {
99            inner,
100            policy,
101            effective_trust: AtomicU8::new(trust_to_u8(TrustLevel::Trusted)),
102            mcp_tool_ids: Arc::new(RwLock::new(HashSet::new())),
103        }
104    }
105
106    /// Returns the shared MCP tool ID set so the caller can populate it after
107    /// MCP servers have connected (and after `TrustGateExecutor` has been wrapped
108    /// in a `DynExecutor`).
109    #[must_use]
110    pub fn mcp_tool_ids_handle(&self) -> Arc<RwLock<HashSet<String>>> {
111        Arc::clone(&self.mcp_tool_ids)
112    }
113
114    pub fn set_effective_trust(&self, level: TrustLevel) {
115        self.effective_trust
116            .store(trust_to_u8(level), Ordering::Relaxed);
117    }
118
119    #[must_use]
120    pub fn effective_trust(&self) -> TrustLevel {
121        u8_to_trust(self.effective_trust.load(Ordering::Relaxed))
122    }
123
124    fn is_mcp_tool(&self, tool_id: &str) -> bool {
125        self.mcp_tool_ids
126            .read()
127            .unwrap_or_else(std::sync::PoisonError::into_inner)
128            .contains(tool_id)
129    }
130
131    fn check_trust(&self, tool_id: &str, input: &str) -> Result<(), ToolError> {
132        match self.effective_trust() {
133            TrustLevel::Blocked => {
134                return Err(ToolError::Blocked {
135                    command: "all tools blocked (trust=blocked)".to_owned(),
136                });
137            }
138            TrustLevel::Quarantined => {
139                if is_quarantine_denied(tool_id) || self.is_mcp_tool(tool_id) {
140                    return Err(ToolError::Blocked {
141                        command: format!("{tool_id} denied (trust=quarantined)"),
142                    });
143                }
144            }
145            TrustLevel::Trusted | TrustLevel::Verified => {}
146        }
147
148        // PermissionPolicy was designed for the bash tool. In Supervised mode, tools
149        // without explicit rules default to Ask, which incorrectly blocks MCP/LSP tools.
150        // Skip the policy check for such tools — trust-level enforcement above is sufficient.
151        // ReadOnly mode is excluded: its allowlist is enforced inside policy.check().
152        if self.policy.autonomy_level() == AutonomyLevel::Supervised
153            && self.policy.rules().get(tool_id).is_none()
154        {
155            return Ok(());
156        }
157
158        match self.policy.check(tool_id, input) {
159            PermissionAction::Allow => Ok(()),
160            PermissionAction::Ask => Err(ToolError::ConfirmationRequired {
161                command: input.to_owned(),
162            }),
163            PermissionAction::Deny => Err(ToolError::Blocked {
164                command: input.to_owned(),
165            }),
166        }
167    }
168}
169
170impl<T: ToolExecutor> ToolExecutor for TrustGateExecutor<T> {
171    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
172        // The legacy fenced-block path does not provide a tool_id, so QUARANTINE_DENIED
173        // cannot be applied selectively. Block entirely for Quarantined to match the
174        // conservative posture: unknown tool identity = deny.
175        match self.effective_trust() {
176            TrustLevel::Blocked | TrustLevel::Quarantined => {
177                return Err(ToolError::Blocked {
178                    command: format!(
179                        "tool execution denied (trust={})",
180                        format!("{:?}", self.effective_trust()).to_lowercase()
181                    ),
182                });
183            }
184            TrustLevel::Trusted | TrustLevel::Verified => {}
185        }
186        self.inner.execute(response).await
187    }
188
189    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
190        // Same rationale as execute(): no tool_id available for QUARANTINE_DENIED check.
191        match self.effective_trust() {
192            TrustLevel::Blocked | TrustLevel::Quarantined => {
193                return Err(ToolError::Blocked {
194                    command: format!(
195                        "tool execution denied (trust={})",
196                        format!("{:?}", self.effective_trust()).to_lowercase()
197                    ),
198                });
199            }
200            TrustLevel::Trusted | TrustLevel::Verified => {}
201        }
202        self.inner.execute_confirmed(response).await
203    }
204
205    fn tool_definitions(&self) -> Vec<ToolDef> {
206        self.inner.tool_definitions()
207    }
208
209    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
210        let input = call
211            .params
212            .get("command")
213            .or_else(|| call.params.get("file_path"))
214            .or_else(|| call.params.get("query"))
215            .or_else(|| call.params.get("url"))
216            .or_else(|| call.params.get("uri"))
217            .and_then(|v| v.as_str())
218            .unwrap_or("");
219        self.check_trust(&call.tool_id, input)?;
220        self.inner.execute_tool_call(call).await
221    }
222
223    async fn execute_tool_call_confirmed(
224        &self,
225        call: &ToolCall,
226    ) -> Result<Option<ToolOutput>, ToolError> {
227        // Bypass check_trust: caller already obtained user approval.
228        // Still enforce Blocked/Quarantined trust level constraints.
229        match self.effective_trust() {
230            TrustLevel::Blocked => {
231                return Err(ToolError::Blocked {
232                    command: "all tools blocked (trust=blocked)".to_owned(),
233                });
234            }
235            TrustLevel::Quarantined => {
236                if is_quarantine_denied(&call.tool_id) || self.is_mcp_tool(&call.tool_id) {
237                    return Err(ToolError::Blocked {
238                        command: format!("{} denied (trust=quarantined)", call.tool_id),
239                    });
240                }
241            }
242            TrustLevel::Trusted | TrustLevel::Verified => {}
243        }
244        self.inner.execute_tool_call_confirmed(call).await
245    }
246
247    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
248        self.inner.set_skill_env(env);
249    }
250
251    fn is_tool_retryable(&self, tool_id: &str) -> bool {
252        self.inner.is_tool_retryable(tool_id)
253    }
254
255    fn set_effective_trust(&self, level: crate::TrustLevel) {
256        self.effective_trust
257            .store(trust_to_u8(level), Ordering::Relaxed);
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[derive(Debug)]
266    struct MockExecutor;
267    impl ToolExecutor for MockExecutor {
268        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
269            Ok(None)
270        }
271        async fn execute_tool_call(
272            &self,
273            call: &ToolCall,
274        ) -> Result<Option<ToolOutput>, ToolError> {
275            Ok(Some(ToolOutput {
276                tool_name: call.tool_id.clone(),
277                summary: "ok".into(),
278                blocks_executed: 1,
279                filter_stats: None,
280                diff: None,
281                streamed: false,
282                terminal_id: None,
283                locations: None,
284                raw_response: None,
285                claim_source: None,
286            }))
287        }
288    }
289
290    fn make_call(tool_id: &str) -> ToolCall {
291        ToolCall {
292            tool_id: tool_id.into(),
293            params: serde_json::Map::new(),
294        }
295    }
296
297    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
298        let mut params = serde_json::Map::new();
299        params.insert("command".into(), serde_json::Value::String(cmd.into()));
300        ToolCall {
301            tool_id: tool_id.into(),
302            params,
303        }
304    }
305
306    #[tokio::test]
307    async fn trusted_allows_all() {
308        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
309        gate.set_effective_trust(TrustLevel::Trusted);
310
311        let result = gate.execute_tool_call(&make_call("bash")).await;
312        // Default policy has no rules for bash => skip policy check => Ok
313        assert!(result.is_ok());
314    }
315
316    #[tokio::test]
317    async fn quarantined_denies_bash() {
318        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
319        gate.set_effective_trust(TrustLevel::Quarantined);
320
321        let result = gate.execute_tool_call(&make_call("bash")).await;
322        assert!(matches!(result, Err(ToolError::Blocked { .. })));
323    }
324
325    #[tokio::test]
326    async fn quarantined_denies_write() {
327        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
328        gate.set_effective_trust(TrustLevel::Quarantined);
329
330        let result = gate.execute_tool_call(&make_call("write")).await;
331        assert!(matches!(result, Err(ToolError::Blocked { .. })));
332    }
333
334    #[tokio::test]
335    async fn quarantined_denies_edit() {
336        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
337        gate.set_effective_trust(TrustLevel::Quarantined);
338
339        let result = gate.execute_tool_call(&make_call("edit")).await;
340        assert!(matches!(result, Err(ToolError::Blocked { .. })));
341    }
342
343    #[tokio::test]
344    async fn quarantined_denies_delete_path() {
345        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
346        gate.set_effective_trust(TrustLevel::Quarantined);
347
348        let result = gate.execute_tool_call(&make_call("delete_path")).await;
349        assert!(matches!(result, Err(ToolError::Blocked { .. })));
350    }
351
352    #[tokio::test]
353    async fn quarantined_denies_fetch() {
354        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
355        gate.set_effective_trust(TrustLevel::Quarantined);
356
357        let result = gate.execute_tool_call(&make_call("fetch")).await;
358        assert!(matches!(result, Err(ToolError::Blocked { .. })));
359    }
360
361    #[tokio::test]
362    async fn quarantined_denies_memory_save() {
363        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
364        gate.set_effective_trust(TrustLevel::Quarantined);
365
366        let result = gate.execute_tool_call(&make_call("memory_save")).await;
367        assert!(matches!(result, Err(ToolError::Blocked { .. })));
368    }
369
370    #[tokio::test]
371    async fn quarantined_allows_read() {
372        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
373        let gate = TrustGateExecutor::new(MockExecutor, policy);
374        gate.set_effective_trust(TrustLevel::Quarantined);
375
376        // "read" (file read) is not in QUARANTINE_DENIED — should be allowed
377        let result = gate.execute_tool_call(&make_call("read")).await;
378        assert!(result.is_ok());
379    }
380
381    #[tokio::test]
382    async fn quarantined_allows_file_read() {
383        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
384        let gate = TrustGateExecutor::new(MockExecutor, policy);
385        gate.set_effective_trust(TrustLevel::Quarantined);
386
387        let result = gate.execute_tool_call(&make_call("file_read")).await;
388        // file_read is not in quarantine denied list, and policy has no rules for file_read => Ok
389        assert!(result.is_ok());
390    }
391
392    #[tokio::test]
393    async fn blocked_denies_everything() {
394        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
395        gate.set_effective_trust(TrustLevel::Blocked);
396
397        let result = gate.execute_tool_call(&make_call("file_read")).await;
398        assert!(matches!(result, Err(ToolError::Blocked { .. })));
399    }
400
401    #[tokio::test]
402    async fn policy_deny_overrides_trust() {
403        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
404        let gate = TrustGateExecutor::new(MockExecutor, policy);
405        gate.set_effective_trust(TrustLevel::Trusted);
406
407        let result = gate
408            .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
409            .await;
410        assert!(matches!(result, Err(ToolError::Blocked { .. })));
411    }
412
413    #[tokio::test]
414    async fn blocked_denies_execute() {
415        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
416        gate.set_effective_trust(TrustLevel::Blocked);
417
418        let result = gate.execute("some response").await;
419        assert!(matches!(result, Err(ToolError::Blocked { .. })));
420    }
421
422    #[tokio::test]
423    async fn blocked_denies_execute_confirmed() {
424        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
425        gate.set_effective_trust(TrustLevel::Blocked);
426
427        let result = gate.execute_confirmed("some response").await;
428        assert!(matches!(result, Err(ToolError::Blocked { .. })));
429    }
430
431    #[tokio::test]
432    async fn trusted_allows_execute() {
433        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
434        gate.set_effective_trust(TrustLevel::Trusted);
435
436        let result = gate.execute("some response").await;
437        assert!(result.is_ok());
438    }
439
440    #[tokio::test]
441    async fn verified_with_allow_policy_succeeds() {
442        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
443        let gate = TrustGateExecutor::new(MockExecutor, policy);
444        gate.set_effective_trust(TrustLevel::Verified);
445
446        let result = gate
447            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
448            .await
449            .unwrap();
450        assert!(result.is_some());
451    }
452
453    #[tokio::test]
454    async fn quarantined_denies_web_scrape() {
455        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
456        gate.set_effective_trust(TrustLevel::Quarantined);
457
458        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
459        assert!(matches!(result, Err(ToolError::Blocked { .. })));
460    }
461
462    #[derive(Debug)]
463    struct EnvCapture {
464        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
465    }
466    impl EnvCapture {
467        fn new() -> Self {
468            Self {
469                captured: std::sync::Mutex::new(None),
470            }
471        }
472    }
473    impl ToolExecutor for EnvCapture {
474        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
475            Ok(None)
476        }
477        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
478            Ok(None)
479        }
480        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
481            *self.captured.lock().unwrap() = env;
482        }
483    }
484
485    #[test]
486    fn is_tool_retryable_delegated_to_inner() {
487        #[derive(Debug)]
488        struct RetryableExecutor;
489        impl ToolExecutor for RetryableExecutor {
490            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
491                Ok(None)
492            }
493            async fn execute_tool_call(
494                &self,
495                _: &ToolCall,
496            ) -> Result<Option<ToolOutput>, ToolError> {
497                Ok(None)
498            }
499            fn is_tool_retryable(&self, tool_id: &str) -> bool {
500                tool_id == "fetch"
501            }
502        }
503        let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
504        assert!(gate.is_tool_retryable("fetch"));
505        assert!(!gate.is_tool_retryable("bash"));
506    }
507
508    #[test]
509    fn set_skill_env_forwarded_to_inner() {
510        let inner = EnvCapture::new();
511        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
512
513        let mut env = std::collections::HashMap::new();
514        env.insert("MY_VAR".to_owned(), "42".to_owned());
515        gate.set_skill_env(Some(env.clone()));
516
517        let captured = gate.inner.captured.lock().unwrap();
518        assert_eq!(*captured, Some(env));
519    }
520
521    #[tokio::test]
522    async fn mcp_tool_supervised_no_rules_allows() {
523        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok
524        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
525        let gate = TrustGateExecutor::new(MockExecutor, policy);
526        gate.set_effective_trust(TrustLevel::Trusted);
527
528        let mut params = serde_json::Map::new();
529        params.insert(
530            "file_path".into(),
531            serde_json::Value::String("/tmp/test.txt".into()),
532        );
533        let call = ToolCall {
534            tool_id: "mcp_filesystem__read_file".into(),
535            params,
536        };
537        let result = gate.execute_tool_call(&call).await;
538        assert!(
539            result.is_ok(),
540            "MCP tool should be allowed when no rules exist"
541        );
542    }
543
544    #[tokio::test]
545    async fn bash_with_explicit_deny_rule_blocked() {
546        // Bash with explicit Deny rule => Err(ToolCallBlocked)
547        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
548        let gate = TrustGateExecutor::new(MockExecutor, policy);
549        gate.set_effective_trust(TrustLevel::Trusted);
550
551        let result = gate
552            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
553            .await;
554        assert!(
555            matches!(result, Err(ToolError::Blocked { .. })),
556            "bash with explicit deny rule should be blocked"
557        );
558    }
559
560    #[tokio::test]
561    async fn bash_with_explicit_allow_rule_succeeds() {
562        // Tool with explicit Allow rules => Ok
563        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
564        let gate = TrustGateExecutor::new(MockExecutor, policy);
565        gate.set_effective_trust(TrustLevel::Trusted);
566
567        let result = gate
568            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
569            .await;
570        assert!(
571            result.is_ok(),
572            "bash with explicit allow rule should succeed"
573        );
574    }
575
576    #[tokio::test]
577    async fn readonly_denies_mcp_tool_not_in_allowlist() {
578        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
579        let policy =
580            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
581        let gate = TrustGateExecutor::new(MockExecutor, policy);
582        gate.set_effective_trust(TrustLevel::Trusted);
583
584        let result = gate
585            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
586            .await;
587        assert!(
588            matches!(result, Err(ToolError::Blocked { .. })),
589            "ReadOnly mode must deny non-allowlisted tools"
590        );
591    }
592
593    #[test]
594    fn set_effective_trust_interior_mutability() {
595        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
596        assert_eq!(gate.effective_trust(), TrustLevel::Trusted);
597
598        gate.set_effective_trust(TrustLevel::Quarantined);
599        assert_eq!(gate.effective_trust(), TrustLevel::Quarantined);
600
601        gate.set_effective_trust(TrustLevel::Blocked);
602        assert_eq!(gate.effective_trust(), TrustLevel::Blocked);
603
604        gate.set_effective_trust(TrustLevel::Trusted);
605        assert_eq!(gate.effective_trust(), TrustLevel::Trusted);
606    }
607
608    // is_quarantine_denied unit tests
609
610    #[test]
611    fn is_quarantine_denied_exact_match() {
612        assert!(is_quarantine_denied("bash"));
613        assert!(is_quarantine_denied("write"));
614        assert!(is_quarantine_denied("fetch"));
615        assert!(is_quarantine_denied("memory_save"));
616        assert!(is_quarantine_denied("delete_path"));
617        assert!(is_quarantine_denied("create_directory"));
618    }
619
620    #[test]
621    fn is_quarantine_denied_suffix_match_mcp_write() {
622        // "filesystem_write" ends with "_write" -> denied
623        assert!(is_quarantine_denied("filesystem_write"));
624        // "filesystem_write_file" ends with "_file", not "_write" -> NOT denied
625        assert!(!is_quarantine_denied("filesystem_write_file"));
626    }
627
628    #[test]
629    fn is_quarantine_denied_suffix_mcp_bash() {
630        assert!(is_quarantine_denied("shell_bash"));
631        assert!(is_quarantine_denied("mcp_shell_bash"));
632    }
633
634    #[test]
635    fn is_quarantine_denied_suffix_mcp_fetch() {
636        assert!(is_quarantine_denied("http_fetch"));
637        // "server_prefetch" ends with "_prefetch", not "_fetch"
638        assert!(!is_quarantine_denied("server_prefetch"));
639    }
640
641    #[test]
642    fn is_quarantine_denied_suffix_mcp_memory_save() {
643        assert!(is_quarantine_denied("server_memory_save"));
644        // "_save" alone does NOT match the multi-word entry "memory_save"
645        assert!(!is_quarantine_denied("server_save"));
646    }
647
648    #[test]
649    fn is_quarantine_denied_suffix_mcp_delete_path() {
650        assert!(is_quarantine_denied("fs_delete_path"));
651        // "fs_not_delete_path" ends with "_delete_path" as well — suffix check is correct
652        assert!(is_quarantine_denied("fs_not_delete_path"));
653    }
654
655    #[test]
656    fn is_quarantine_denied_substring_not_suffix() {
657        // "write_log" ends with "_log", NOT "_write" — must NOT be denied
658        assert!(!is_quarantine_denied("write_log"));
659    }
660
661    #[test]
662    fn is_quarantine_denied_read_only_tools_allowed() {
663        assert!(!is_quarantine_denied("filesystem_read_file"));
664        assert!(!is_quarantine_denied("filesystem_list_dir"));
665        assert!(!is_quarantine_denied("read"));
666        assert!(!is_quarantine_denied("file_read"));
667    }
668
669    #[tokio::test]
670    async fn quarantined_denies_mcp_write_tool() {
671        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
672        gate.set_effective_trust(TrustLevel::Quarantined);
673
674        let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
675        assert!(matches!(result, Err(ToolError::Blocked { .. })));
676    }
677
678    #[tokio::test]
679    async fn quarantined_allows_mcp_read_file() {
680        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
681        let gate = TrustGateExecutor::new(MockExecutor, policy);
682        gate.set_effective_trust(TrustLevel::Quarantined);
683
684        let result = gate
685            .execute_tool_call(&make_call("filesystem_read_file"))
686            .await;
687        assert!(result.is_ok());
688    }
689
690    #[tokio::test]
691    async fn quarantined_denies_mcp_bash_tool() {
692        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
693        gate.set_effective_trust(TrustLevel::Quarantined);
694
695        let result = gate.execute_tool_call(&make_call("shell_bash")).await;
696        assert!(matches!(result, Err(ToolError::Blocked { .. })));
697    }
698
699    #[tokio::test]
700    async fn quarantined_denies_mcp_memory_save() {
701        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
702        gate.set_effective_trust(TrustLevel::Quarantined);
703
704        let result = gate
705            .execute_tool_call(&make_call("server_memory_save"))
706            .await;
707        assert!(matches!(result, Err(ToolError::Blocked { .. })));
708    }
709
710    #[tokio::test]
711    async fn quarantined_denies_mcp_confirmed_path() {
712        // execute_tool_call_confirmed also enforces quarantine via is_quarantine_denied
713        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
714        gate.set_effective_trust(TrustLevel::Quarantined);
715
716        let result = gate
717            .execute_tool_call_confirmed(&make_call("filesystem_write"))
718            .await;
719        assert!(matches!(result, Err(ToolError::Blocked { .. })));
720    }
721
722    // mcp_tool_ids registry tests
723
724    fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
725        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
726        let handle = gate.mcp_tool_ids_handle();
727        let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
728        *handle.write().unwrap() = set;
729        gate
730    }
731
732    #[tokio::test]
733    async fn quarantined_denies_registered_mcp_tool_novel_name() {
734        // "github_run_command" has no QUARANTINE_DENIED suffix match, but is registered as MCP.
735        let gate = gate_with_mcp_ids(&["github_run_command"]);
736        gate.set_effective_trust(TrustLevel::Quarantined);
737
738        let result = gate
739            .execute_tool_call(&make_call("github_run_command"))
740            .await;
741        assert!(matches!(result, Err(ToolError::Blocked { .. })));
742    }
743
744    #[tokio::test]
745    async fn quarantined_denies_registered_mcp_tool_execute() {
746        // "shell_execute" — no suffix match on "execute", but registered as MCP.
747        let gate = gate_with_mcp_ids(&["shell_execute"]);
748        gate.set_effective_trust(TrustLevel::Quarantined);
749
750        let result = gate.execute_tool_call(&make_call("shell_execute")).await;
751        assert!(matches!(result, Err(ToolError::Blocked { .. })));
752    }
753
754    #[tokio::test]
755    async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
756        // Tool not in MCP set and not in QUARANTINE_DENIED — allowed.
757        let gate = gate_with_mcp_ids(&["other_tool"]);
758        gate.set_effective_trust(TrustLevel::Quarantined);
759
760        let result = gate.execute_tool_call(&make_call("read")).await;
761        assert!(result.is_ok());
762    }
763
764    #[tokio::test]
765    async fn trusted_allows_registered_mcp_tool() {
766        // At Trusted level, MCP registry check must NOT fire.
767        let gate = gate_with_mcp_ids(&["github_run_command"]);
768        gate.set_effective_trust(TrustLevel::Trusted);
769
770        let result = gate
771            .execute_tool_call(&make_call("github_run_command"))
772            .await;
773        assert!(result.is_ok());
774    }
775
776    #[tokio::test]
777    async fn quarantined_denies_mcp_tool_via_confirmed_path() {
778        // execute_tool_call_confirmed must also check the MCP registry.
779        let gate = gate_with_mcp_ids(&["docker_container_exec"]);
780        gate.set_effective_trust(TrustLevel::Quarantined);
781
782        let result = gate
783            .execute_tool_call_confirmed(&make_call("docker_container_exec"))
784            .await;
785        assert!(matches!(result, Err(ToolError::Blocked { .. })));
786    }
787
788    #[test]
789    fn mcp_tool_ids_handle_shared_arc() {
790        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
791        let handle = gate.mcp_tool_ids_handle();
792        handle.write().unwrap().insert("test_tool".to_owned());
793        assert!(gate.is_mcp_tool("test_tool"));
794        assert!(!gate.is_mcp_tool("other_tool"));
795    }
796}