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