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