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::SkillTrustLevel;
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: SkillTrustLevel) -> u8 {
55    match level {
56        SkillTrustLevel::Trusted => 0,
57        SkillTrustLevel::Verified => 1,
58        SkillTrustLevel::Quarantined => 2,
59        SkillTrustLevel::Blocked => 3,
60    }
61}
62
63fn u8_to_trust(v: u8) -> SkillTrustLevel {
64    match v {
65        0 => SkillTrustLevel::Trusted,
66        1 => SkillTrustLevel::Verified,
67        2 => SkillTrustLevel::Quarantined,
68        _ => SkillTrustLevel::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(SkillTrustLevel::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: SkillTrustLevel) {
115        self.effective_trust
116            .store(trust_to_u8(level), Ordering::Relaxed);
117    }
118
119    #[must_use]
120    pub fn effective_trust(&self) -> SkillTrustLevel {
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            SkillTrustLevel::Blocked => {
134                return Err(ToolError::Blocked {
135                    command: "all tools blocked (trust=blocked)".to_owned(),
136                });
137            }
138            SkillTrustLevel::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            SkillTrustLevel::Trusted | SkillTrustLevel::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            SkillTrustLevel::Blocked | SkillTrustLevel::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            SkillTrustLevel::Trusted | SkillTrustLevel::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            SkillTrustLevel::Blocked | SkillTrustLevel::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            SkillTrustLevel::Trusted | SkillTrustLevel::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            SkillTrustLevel::Blocked => {
231                return Err(ToolError::Blocked {
232                    command: "all tools blocked (trust=blocked)".to_owned(),
233                });
234            }
235            SkillTrustLevel::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            SkillTrustLevel::Trusted | SkillTrustLevel::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::SkillTrustLevel) {
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            caller_id: None,
295        }
296    }
297
298    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
299        let mut params = serde_json::Map::new();
300        params.insert("command".into(), serde_json::Value::String(cmd.into()));
301        ToolCall {
302            tool_id: tool_id.into(),
303            params,
304            caller_id: None,
305        }
306    }
307
308    #[tokio::test]
309    async fn trusted_allows_all() {
310        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
311        gate.set_effective_trust(SkillTrustLevel::Trusted);
312
313        let result = gate.execute_tool_call(&make_call("bash")).await;
314        // Default policy has no rules for bash => skip policy check => Ok
315        assert!(result.is_ok());
316    }
317
318    #[tokio::test]
319    async fn quarantined_denies_bash() {
320        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
321        gate.set_effective_trust(SkillTrustLevel::Quarantined);
322
323        let result = gate.execute_tool_call(&make_call("bash")).await;
324        assert!(matches!(result, Err(ToolError::Blocked { .. })));
325    }
326
327    #[tokio::test]
328    async fn quarantined_denies_write() {
329        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
330        gate.set_effective_trust(SkillTrustLevel::Quarantined);
331
332        let result = gate.execute_tool_call(&make_call("write")).await;
333        assert!(matches!(result, Err(ToolError::Blocked { .. })));
334    }
335
336    #[tokio::test]
337    async fn quarantined_denies_edit() {
338        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
339        gate.set_effective_trust(SkillTrustLevel::Quarantined);
340
341        let result = gate.execute_tool_call(&make_call("edit")).await;
342        assert!(matches!(result, Err(ToolError::Blocked { .. })));
343    }
344
345    #[tokio::test]
346    async fn quarantined_denies_delete_path() {
347        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
348        gate.set_effective_trust(SkillTrustLevel::Quarantined);
349
350        let result = gate.execute_tool_call(&make_call("delete_path")).await;
351        assert!(matches!(result, Err(ToolError::Blocked { .. })));
352    }
353
354    #[tokio::test]
355    async fn quarantined_denies_fetch() {
356        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
357        gate.set_effective_trust(SkillTrustLevel::Quarantined);
358
359        let result = gate.execute_tool_call(&make_call("fetch")).await;
360        assert!(matches!(result, Err(ToolError::Blocked { .. })));
361    }
362
363    #[tokio::test]
364    async fn quarantined_denies_memory_save() {
365        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
366        gate.set_effective_trust(SkillTrustLevel::Quarantined);
367
368        let result = gate.execute_tool_call(&make_call("memory_save")).await;
369        assert!(matches!(result, Err(ToolError::Blocked { .. })));
370    }
371
372    #[tokio::test]
373    async fn quarantined_allows_read() {
374        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
375        let gate = TrustGateExecutor::new(MockExecutor, policy);
376        gate.set_effective_trust(SkillTrustLevel::Quarantined);
377
378        // "read" (file read) is not in QUARANTINE_DENIED — should be allowed
379        let result = gate.execute_tool_call(&make_call("read")).await;
380        assert!(result.is_ok());
381    }
382
383    #[tokio::test]
384    async fn quarantined_allows_file_read() {
385        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
386        let gate = TrustGateExecutor::new(MockExecutor, policy);
387        gate.set_effective_trust(SkillTrustLevel::Quarantined);
388
389        let result = gate.execute_tool_call(&make_call("file_read")).await;
390        // file_read is not in quarantine denied list, and policy has no rules for file_read => Ok
391        assert!(result.is_ok());
392    }
393
394    #[tokio::test]
395    async fn blocked_denies_everything() {
396        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
397        gate.set_effective_trust(SkillTrustLevel::Blocked);
398
399        let result = gate.execute_tool_call(&make_call("file_read")).await;
400        assert!(matches!(result, Err(ToolError::Blocked { .. })));
401    }
402
403    #[tokio::test]
404    async fn policy_deny_overrides_trust() {
405        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
406        let gate = TrustGateExecutor::new(MockExecutor, policy);
407        gate.set_effective_trust(SkillTrustLevel::Trusted);
408
409        let result = gate
410            .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
411            .await;
412        assert!(matches!(result, Err(ToolError::Blocked { .. })));
413    }
414
415    #[tokio::test]
416    async fn blocked_denies_execute() {
417        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
418        gate.set_effective_trust(SkillTrustLevel::Blocked);
419
420        let result = gate.execute("some response").await;
421        assert!(matches!(result, Err(ToolError::Blocked { .. })));
422    }
423
424    #[tokio::test]
425    async fn blocked_denies_execute_confirmed() {
426        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
427        gate.set_effective_trust(SkillTrustLevel::Blocked);
428
429        let result = gate.execute_confirmed("some response").await;
430        assert!(matches!(result, Err(ToolError::Blocked { .. })));
431    }
432
433    #[tokio::test]
434    async fn trusted_allows_execute() {
435        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
436        gate.set_effective_trust(SkillTrustLevel::Trusted);
437
438        let result = gate.execute("some response").await;
439        assert!(result.is_ok());
440    }
441
442    #[tokio::test]
443    async fn verified_with_allow_policy_succeeds() {
444        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
445        let gate = TrustGateExecutor::new(MockExecutor, policy);
446        gate.set_effective_trust(SkillTrustLevel::Verified);
447
448        let result = gate
449            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
450            .await
451            .unwrap();
452        assert!(result.is_some());
453    }
454
455    #[tokio::test]
456    async fn quarantined_denies_web_scrape() {
457        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
458        gate.set_effective_trust(SkillTrustLevel::Quarantined);
459
460        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
461        assert!(matches!(result, Err(ToolError::Blocked { .. })));
462    }
463
464    #[derive(Debug)]
465    struct EnvCapture {
466        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
467    }
468    impl EnvCapture {
469        fn new() -> Self {
470            Self {
471                captured: std::sync::Mutex::new(None),
472            }
473        }
474    }
475    impl ToolExecutor for EnvCapture {
476        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
477            Ok(None)
478        }
479        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
480            Ok(None)
481        }
482        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
483            *self.captured.lock().unwrap() = env;
484        }
485    }
486
487    #[test]
488    fn is_tool_retryable_delegated_to_inner() {
489        #[derive(Debug)]
490        struct RetryableExecutor;
491        impl ToolExecutor for RetryableExecutor {
492            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
493                Ok(None)
494            }
495            async fn execute_tool_call(
496                &self,
497                _: &ToolCall,
498            ) -> Result<Option<ToolOutput>, ToolError> {
499                Ok(None)
500            }
501            fn is_tool_retryable(&self, tool_id: &str) -> bool {
502                tool_id == "fetch"
503            }
504        }
505        let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
506        assert!(gate.is_tool_retryable("fetch"));
507        assert!(!gate.is_tool_retryable("bash"));
508    }
509
510    #[test]
511    fn set_skill_env_forwarded_to_inner() {
512        let inner = EnvCapture::new();
513        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
514
515        let mut env = std::collections::HashMap::new();
516        env.insert("MY_VAR".to_owned(), "42".to_owned());
517        gate.set_skill_env(Some(env.clone()));
518
519        let captured = gate.inner.captured.lock().unwrap();
520        assert_eq!(*captured, Some(env));
521    }
522
523    #[tokio::test]
524    async fn mcp_tool_supervised_no_rules_allows() {
525        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok
526        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
527        let gate = TrustGateExecutor::new(MockExecutor, policy);
528        gate.set_effective_trust(SkillTrustLevel::Trusted);
529
530        let mut params = serde_json::Map::new();
531        params.insert(
532            "file_path".into(),
533            serde_json::Value::String("/tmp/test.txt".into()),
534        );
535        let call = ToolCall {
536            tool_id: "mcp_filesystem__read_file".into(),
537            params,
538            caller_id: None,
539        };
540        let result = gate.execute_tool_call(&call).await;
541        assert!(
542            result.is_ok(),
543            "MCP tool should be allowed when no rules exist"
544        );
545    }
546
547    #[tokio::test]
548    async fn bash_with_explicit_deny_rule_blocked() {
549        // Bash with explicit Deny rule => Err(ToolCallBlocked)
550        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
551        let gate = TrustGateExecutor::new(MockExecutor, policy);
552        gate.set_effective_trust(SkillTrustLevel::Trusted);
553
554        let result = gate
555            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
556            .await;
557        assert!(
558            matches!(result, Err(ToolError::Blocked { .. })),
559            "bash with explicit deny rule should be blocked"
560        );
561    }
562
563    #[tokio::test]
564    async fn bash_with_explicit_allow_rule_succeeds() {
565        // Tool with explicit Allow rules => Ok
566        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
567        let gate = TrustGateExecutor::new(MockExecutor, policy);
568        gate.set_effective_trust(SkillTrustLevel::Trusted);
569
570        let result = gate
571            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
572            .await;
573        assert!(
574            result.is_ok(),
575            "bash with explicit allow rule should succeed"
576        );
577    }
578
579    #[tokio::test]
580    async fn readonly_denies_mcp_tool_not_in_allowlist() {
581        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
582        let policy =
583            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
584        let gate = TrustGateExecutor::new(MockExecutor, policy);
585        gate.set_effective_trust(SkillTrustLevel::Trusted);
586
587        let result = gate
588            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
589            .await;
590        assert!(
591            matches!(result, Err(ToolError::Blocked { .. })),
592            "ReadOnly mode must deny non-allowlisted tools"
593        );
594    }
595
596    #[test]
597    fn set_effective_trust_interior_mutability() {
598        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
599        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
600
601        gate.set_effective_trust(SkillTrustLevel::Quarantined);
602        assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
603
604        gate.set_effective_trust(SkillTrustLevel::Blocked);
605        assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
606
607        gate.set_effective_trust(SkillTrustLevel::Trusted);
608        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
609    }
610
611    // is_quarantine_denied unit tests
612
613    #[test]
614    fn is_quarantine_denied_exact_match() {
615        assert!(is_quarantine_denied("bash"));
616        assert!(is_quarantine_denied("write"));
617        assert!(is_quarantine_denied("fetch"));
618        assert!(is_quarantine_denied("memory_save"));
619        assert!(is_quarantine_denied("delete_path"));
620        assert!(is_quarantine_denied("create_directory"));
621    }
622
623    #[test]
624    fn is_quarantine_denied_suffix_match_mcp_write() {
625        // "filesystem_write" ends with "_write" -> denied
626        assert!(is_quarantine_denied("filesystem_write"));
627        // "filesystem_write_file" ends with "_file", not "_write" -> NOT denied
628        assert!(!is_quarantine_denied("filesystem_write_file"));
629    }
630
631    #[test]
632    fn is_quarantine_denied_suffix_mcp_bash() {
633        assert!(is_quarantine_denied("shell_bash"));
634        assert!(is_quarantine_denied("mcp_shell_bash"));
635    }
636
637    #[test]
638    fn is_quarantine_denied_suffix_mcp_fetch() {
639        assert!(is_quarantine_denied("http_fetch"));
640        // "server_prefetch" ends with "_prefetch", not "_fetch"
641        assert!(!is_quarantine_denied("server_prefetch"));
642    }
643
644    #[test]
645    fn is_quarantine_denied_suffix_mcp_memory_save() {
646        assert!(is_quarantine_denied("server_memory_save"));
647        // "_save" alone does NOT match the multi-word entry "memory_save"
648        assert!(!is_quarantine_denied("server_save"));
649    }
650
651    #[test]
652    fn is_quarantine_denied_suffix_mcp_delete_path() {
653        assert!(is_quarantine_denied("fs_delete_path"));
654        // "fs_not_delete_path" ends with "_delete_path" as well — suffix check is correct
655        assert!(is_quarantine_denied("fs_not_delete_path"));
656    }
657
658    #[test]
659    fn is_quarantine_denied_substring_not_suffix() {
660        // "write_log" ends with "_log", NOT "_write" — must NOT be denied
661        assert!(!is_quarantine_denied("write_log"));
662    }
663
664    #[test]
665    fn is_quarantine_denied_read_only_tools_allowed() {
666        assert!(!is_quarantine_denied("filesystem_read_file"));
667        assert!(!is_quarantine_denied("filesystem_list_dir"));
668        assert!(!is_quarantine_denied("read"));
669        assert!(!is_quarantine_denied("file_read"));
670    }
671
672    #[tokio::test]
673    async fn quarantined_denies_mcp_write_tool() {
674        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
675        gate.set_effective_trust(SkillTrustLevel::Quarantined);
676
677        let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
678        assert!(matches!(result, Err(ToolError::Blocked { .. })));
679    }
680
681    #[tokio::test]
682    async fn quarantined_allows_mcp_read_file() {
683        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
684        let gate = TrustGateExecutor::new(MockExecutor, policy);
685        gate.set_effective_trust(SkillTrustLevel::Quarantined);
686
687        let result = gate
688            .execute_tool_call(&make_call("filesystem_read_file"))
689            .await;
690        assert!(result.is_ok());
691    }
692
693    #[tokio::test]
694    async fn quarantined_denies_mcp_bash_tool() {
695        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
696        gate.set_effective_trust(SkillTrustLevel::Quarantined);
697
698        let result = gate.execute_tool_call(&make_call("shell_bash")).await;
699        assert!(matches!(result, Err(ToolError::Blocked { .. })));
700    }
701
702    #[tokio::test]
703    async fn quarantined_denies_mcp_memory_save() {
704        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
705        gate.set_effective_trust(SkillTrustLevel::Quarantined);
706
707        let result = gate
708            .execute_tool_call(&make_call("server_memory_save"))
709            .await;
710        assert!(matches!(result, Err(ToolError::Blocked { .. })));
711    }
712
713    #[tokio::test]
714    async fn quarantined_denies_mcp_confirmed_path() {
715        // execute_tool_call_confirmed also enforces quarantine via is_quarantine_denied
716        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
717        gate.set_effective_trust(SkillTrustLevel::Quarantined);
718
719        let result = gate
720            .execute_tool_call_confirmed(&make_call("filesystem_write"))
721            .await;
722        assert!(matches!(result, Err(ToolError::Blocked { .. })));
723    }
724
725    // mcp_tool_ids registry tests
726
727    fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
728        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
729        let handle = gate.mcp_tool_ids_handle();
730        let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
731        *handle.write().unwrap() = set;
732        gate
733    }
734
735    #[tokio::test]
736    async fn quarantined_denies_registered_mcp_tool_novel_name() {
737        // "github_run_command" has no QUARANTINE_DENIED suffix match, but is registered as MCP.
738        let gate = gate_with_mcp_ids(&["github_run_command"]);
739        gate.set_effective_trust(SkillTrustLevel::Quarantined);
740
741        let result = gate
742            .execute_tool_call(&make_call("github_run_command"))
743            .await;
744        assert!(matches!(result, Err(ToolError::Blocked { .. })));
745    }
746
747    #[tokio::test]
748    async fn quarantined_denies_registered_mcp_tool_execute() {
749        // "shell_execute" — no suffix match on "execute", but registered as MCP.
750        let gate = gate_with_mcp_ids(&["shell_execute"]);
751        gate.set_effective_trust(SkillTrustLevel::Quarantined);
752
753        let result = gate.execute_tool_call(&make_call("shell_execute")).await;
754        assert!(matches!(result, Err(ToolError::Blocked { .. })));
755    }
756
757    #[tokio::test]
758    async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
759        // Tool not in MCP set and not in QUARANTINE_DENIED — allowed.
760        let gate = gate_with_mcp_ids(&["other_tool"]);
761        gate.set_effective_trust(SkillTrustLevel::Quarantined);
762
763        let result = gate.execute_tool_call(&make_call("read")).await;
764        assert!(result.is_ok());
765    }
766
767    #[tokio::test]
768    async fn trusted_allows_registered_mcp_tool() {
769        // At Trusted level, MCP registry check must NOT fire.
770        let gate = gate_with_mcp_ids(&["github_run_command"]);
771        gate.set_effective_trust(SkillTrustLevel::Trusted);
772
773        let result = gate
774            .execute_tool_call(&make_call("github_run_command"))
775            .await;
776        assert!(result.is_ok());
777    }
778
779    #[tokio::test]
780    async fn quarantined_denies_mcp_tool_via_confirmed_path() {
781        // execute_tool_call_confirmed must also check the MCP registry.
782        let gate = gate_with_mcp_ids(&["docker_container_exec"]);
783        gate.set_effective_trust(SkillTrustLevel::Quarantined);
784
785        let result = gate
786            .execute_tool_call_confirmed(&make_call("docker_container_exec"))
787            .await;
788        assert!(matches!(result, Err(ToolError::Blocked { .. })));
789    }
790
791    #[test]
792    fn mcp_tool_ids_handle_shared_arc() {
793        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
794        let handle = gate.mcp_tool_ids_handle();
795        handle.write().unwrap().insert("test_tool".to_owned());
796        assert!(gate.is_mcp_tool("test_tool"));
797        assert!(!gate.is_mcp_tool("other_tool"));
798    }
799}