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        SkillTrustLevel::Blocked => 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                    return Err(ToolError::Blocked {
117                        command: format!("{tool_id} denied (trust=quarantined)"),
118                    });
119                }
120            }
121            SkillTrustLevel::Trusted | SkillTrustLevel::Verified => {}
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            PermissionAction::Deny => 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            SkillTrustLevel::Trusted | SkillTrustLevel::Verified => {}
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            SkillTrustLevel::Trusted | SkillTrustLevel::Verified => {}
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            SkillTrustLevel::Trusted | SkillTrustLevel::Verified => {}
221        }
222        self.inner.execute_tool_call_confirmed(call).await
223    }
224
225    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
226        self.inner.set_skill_env(env);
227    }
228
229    fn is_tool_retryable(&self, tool_id: &str) -> bool {
230        self.inner.is_tool_retryable(tool_id)
231    }
232
233    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
234        self.inner.is_tool_speculatable(tool_id)
235    }
236
237    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
238        self.effective_trust
239            .store(trust_to_u8(level), Ordering::Relaxed);
240    }
241
242    /// Returns `true` when the current policy would require confirmation for `call`.
243    ///
244    /// Mirrors the decision in [`execute_tool_call`](Self::execute_tool_call) without
245    /// executing the tool. The speculative engine calls this to skip dispatch for tools
246    /// that require user approval.
247    fn requires_confirmation(&self, call: &crate::executor::ToolCall) -> bool {
248        let input = call
249            .params
250            .get("command")
251            .or_else(|| call.params.get("file_path"))
252            .or_else(|| call.params.get("query"))
253            .or_else(|| call.params.get("url"))
254            .or_else(|| call.params.get("uri"))
255            .and_then(|v| v.as_str())
256            .unwrap_or("");
257        matches!(
258            self.check_trust(call.tool_id.as_str(), input),
259            Err(ToolError::ConfirmationRequired { .. })
260        )
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[derive(Debug)]
269    struct MockExecutor;
270    impl ToolExecutor for MockExecutor {
271        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
272            Ok(None)
273        }
274        async fn execute_tool_call(
275            &self,
276            call: &ToolCall,
277        ) -> Result<Option<ToolOutput>, ToolError> {
278            Ok(Some(ToolOutput {
279                tool_name: call.tool_id.clone(),
280                summary: "ok".into(),
281                blocks_executed: 1,
282                filter_stats: None,
283                diff: None,
284                streamed: false,
285                terminal_id: None,
286                locations: None,
287                raw_response: None,
288                claim_source: None,
289            }))
290        }
291    }
292
293    fn make_call(tool_id: &str) -> ToolCall {
294        ToolCall {
295            tool_id: tool_id.into(),
296            params: serde_json::Map::new(),
297            caller_id: None,
298            context: None,
299        }
300    }
301
302    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
303        let mut params = serde_json::Map::new();
304        params.insert("command".into(), serde_json::Value::String(cmd.into()));
305        ToolCall {
306            tool_id: tool_id.into(),
307            params,
308            caller_id: None,
309            context: None,
310        }
311    }
312
313    #[tokio::test]
314    async fn trusted_allows_all() {
315        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
316        gate.set_effective_trust(SkillTrustLevel::Trusted);
317
318        let result = gate.execute_tool_call(&make_call("bash")).await;
319        // Default policy has no rules for bash => skip policy check => Ok
320        assert!(result.is_ok());
321    }
322
323    #[tokio::test]
324    async fn quarantined_denies_bash() {
325        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
326        gate.set_effective_trust(SkillTrustLevel::Quarantined);
327
328        let result = gate.execute_tool_call(&make_call("bash")).await;
329        assert!(matches!(result, Err(ToolError::Blocked { .. })));
330    }
331
332    #[tokio::test]
333    async fn quarantined_denies_write() {
334        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
335        gate.set_effective_trust(SkillTrustLevel::Quarantined);
336
337        let result = gate.execute_tool_call(&make_call("write")).await;
338        assert!(matches!(result, Err(ToolError::Blocked { .. })));
339    }
340
341    #[tokio::test]
342    async fn quarantined_denies_edit() {
343        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
344        gate.set_effective_trust(SkillTrustLevel::Quarantined);
345
346        let result = gate.execute_tool_call(&make_call("edit")).await;
347        assert!(matches!(result, Err(ToolError::Blocked { .. })));
348    }
349
350    #[tokio::test]
351    async fn quarantined_denies_delete_path() {
352        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
353        gate.set_effective_trust(SkillTrustLevel::Quarantined);
354
355        let result = gate.execute_tool_call(&make_call("delete_path")).await;
356        assert!(matches!(result, Err(ToolError::Blocked { .. })));
357    }
358
359    #[tokio::test]
360    async fn quarantined_denies_fetch() {
361        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
362        gate.set_effective_trust(SkillTrustLevel::Quarantined);
363
364        let result = gate.execute_tool_call(&make_call("fetch")).await;
365        assert!(matches!(result, Err(ToolError::Blocked { .. })));
366    }
367
368    #[tokio::test]
369    async fn quarantined_denies_memory_save() {
370        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
371        gate.set_effective_trust(SkillTrustLevel::Quarantined);
372
373        let result = gate.execute_tool_call(&make_call("memory_save")).await;
374        assert!(matches!(result, Err(ToolError::Blocked { .. })));
375    }
376
377    #[tokio::test]
378    async fn quarantined_allows_read() {
379        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
380        let gate = TrustGateExecutor::new(MockExecutor, policy);
381        gate.set_effective_trust(SkillTrustLevel::Quarantined);
382
383        // "read" (file read) is not in QUARANTINE_DENIED — should be allowed
384        let result = gate.execute_tool_call(&make_call("read")).await;
385        assert!(result.is_ok());
386    }
387
388    #[tokio::test]
389    async fn quarantined_allows_file_read() {
390        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
391        let gate = TrustGateExecutor::new(MockExecutor, policy);
392        gate.set_effective_trust(SkillTrustLevel::Quarantined);
393
394        let result = gate.execute_tool_call(&make_call("file_read")).await;
395        // file_read is not in quarantine denied list, and policy has no rules for file_read => Ok
396        assert!(result.is_ok());
397    }
398
399    #[tokio::test]
400    async fn blocked_denies_everything() {
401        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
402        gate.set_effective_trust(SkillTrustLevel::Blocked);
403
404        let result = gate.execute_tool_call(&make_call("file_read")).await;
405        assert!(matches!(result, Err(ToolError::Blocked { .. })));
406    }
407
408    #[tokio::test]
409    async fn policy_deny_overrides_trust() {
410        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
411        let gate = TrustGateExecutor::new(MockExecutor, policy);
412        gate.set_effective_trust(SkillTrustLevel::Trusted);
413
414        let result = gate
415            .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
416            .await;
417        assert!(matches!(result, Err(ToolError::Blocked { .. })));
418    }
419
420    #[tokio::test]
421    async fn blocked_denies_execute() {
422        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
423        gate.set_effective_trust(SkillTrustLevel::Blocked);
424
425        let result = gate.execute("some response").await;
426        assert!(matches!(result, Err(ToolError::Blocked { .. })));
427    }
428
429    #[tokio::test]
430    async fn blocked_denies_execute_confirmed() {
431        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
432        gate.set_effective_trust(SkillTrustLevel::Blocked);
433
434        let result = gate.execute_confirmed("some response").await;
435        assert!(matches!(result, Err(ToolError::Blocked { .. })));
436    }
437
438    #[tokio::test]
439    async fn trusted_allows_execute() {
440        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
441        gate.set_effective_trust(SkillTrustLevel::Trusted);
442
443        let result = gate.execute("some response").await;
444        assert!(result.is_ok());
445    }
446
447    #[tokio::test]
448    async fn verified_with_allow_policy_succeeds() {
449        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
450        let gate = TrustGateExecutor::new(MockExecutor, policy);
451        gate.set_effective_trust(SkillTrustLevel::Verified);
452
453        let result = gate
454            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
455            .await
456            .unwrap();
457        assert!(result.is_some());
458    }
459
460    #[tokio::test]
461    async fn quarantined_denies_web_scrape() {
462        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
463        gate.set_effective_trust(SkillTrustLevel::Quarantined);
464
465        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
466        assert!(matches!(result, Err(ToolError::Blocked { .. })));
467    }
468
469    #[derive(Debug)]
470    struct EnvCapture {
471        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
472    }
473    impl EnvCapture {
474        fn new() -> Self {
475            Self {
476                captured: std::sync::Mutex::new(None),
477            }
478        }
479    }
480    impl ToolExecutor for EnvCapture {
481        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
482            Ok(None)
483        }
484        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
485            Ok(None)
486        }
487        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
488            *self.captured.lock().unwrap() = env;
489        }
490    }
491
492    #[test]
493    fn is_tool_retryable_delegated_to_inner() {
494        #[derive(Debug)]
495        struct RetryableExecutor;
496        impl ToolExecutor for RetryableExecutor {
497            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
498                Ok(None)
499            }
500            async fn execute_tool_call(
501                &self,
502                _: &ToolCall,
503            ) -> Result<Option<ToolOutput>, ToolError> {
504                Ok(None)
505            }
506            fn is_tool_retryable(&self, tool_id: &str) -> bool {
507                tool_id == "fetch"
508            }
509        }
510        let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
511        assert!(gate.is_tool_retryable("fetch"));
512        assert!(!gate.is_tool_retryable("bash"));
513    }
514
515    #[test]
516    fn set_skill_env_forwarded_to_inner() {
517        let inner = EnvCapture::new();
518        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
519
520        let mut env = std::collections::HashMap::new();
521        env.insert("MY_VAR".to_owned(), "42".to_owned());
522        gate.set_skill_env(Some(env.clone()));
523
524        let captured = gate.inner.captured.lock().unwrap();
525        assert_eq!(*captured, Some(env));
526    }
527
528    #[tokio::test]
529    async fn mcp_tool_supervised_no_rules_allows() {
530        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok
531        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
532        let gate = TrustGateExecutor::new(MockExecutor, policy);
533        gate.set_effective_trust(SkillTrustLevel::Trusted);
534
535        let mut params = serde_json::Map::new();
536        params.insert(
537            "file_path".into(),
538            serde_json::Value::String("/tmp/test.txt".into()),
539        );
540        let call = ToolCall {
541            tool_id: "mcp_filesystem__read_file".into(),
542            params,
543            caller_id: None,
544            context: None,
545        };
546        let result = gate.execute_tool_call(&call).await;
547        assert!(
548            result.is_ok(),
549            "MCP tool should be allowed when no rules exist"
550        );
551    }
552
553    #[tokio::test]
554    async fn bash_with_explicit_deny_rule_blocked() {
555        // Bash with explicit Deny rule => Err(ToolCallBlocked)
556        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
557        let gate = TrustGateExecutor::new(MockExecutor, policy);
558        gate.set_effective_trust(SkillTrustLevel::Trusted);
559
560        let result = gate
561            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
562            .await;
563        assert!(
564            matches!(result, Err(ToolError::Blocked { .. })),
565            "bash with explicit deny rule should be blocked"
566        );
567    }
568
569    #[tokio::test]
570    async fn bash_with_explicit_allow_rule_succeeds() {
571        // Tool with explicit Allow rules => Ok
572        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
573        let gate = TrustGateExecutor::new(MockExecutor, policy);
574        gate.set_effective_trust(SkillTrustLevel::Trusted);
575
576        let result = gate
577            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
578            .await;
579        assert!(
580            result.is_ok(),
581            "bash with explicit allow rule should succeed"
582        );
583    }
584
585    #[tokio::test]
586    async fn readonly_denies_mcp_tool_not_in_allowlist() {
587        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
588        let policy =
589            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
590        let gate = TrustGateExecutor::new(MockExecutor, policy);
591        gate.set_effective_trust(SkillTrustLevel::Trusted);
592
593        let result = gate
594            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
595            .await;
596        assert!(
597            matches!(result, Err(ToolError::Blocked { .. })),
598            "ReadOnly mode must deny non-allowlisted tools"
599        );
600    }
601
602    #[test]
603    fn set_effective_trust_interior_mutability() {
604        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
605        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
606
607        gate.set_effective_trust(SkillTrustLevel::Quarantined);
608        assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
609
610        gate.set_effective_trust(SkillTrustLevel::Blocked);
611        assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
612
613        gate.set_effective_trust(SkillTrustLevel::Trusted);
614        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
615    }
616
617    // is_quarantine_denied unit tests
618
619    #[test]
620    fn is_quarantine_denied_exact_match() {
621        assert!(is_quarantine_denied("bash"));
622        assert!(is_quarantine_denied("write"));
623        assert!(is_quarantine_denied("fetch"));
624        assert!(is_quarantine_denied("memory_save"));
625        assert!(is_quarantine_denied("delete_path"));
626        assert!(is_quarantine_denied("create_directory"));
627    }
628
629    #[test]
630    fn is_quarantine_denied_suffix_match_mcp_write() {
631        // "filesystem_write" ends with "_write" -> denied
632        assert!(is_quarantine_denied("filesystem_write"));
633        // "filesystem_write_file" ends with "_file", not "_write" -> NOT denied
634        assert!(!is_quarantine_denied("filesystem_write_file"));
635    }
636
637    #[test]
638    fn is_quarantine_denied_suffix_mcp_bash() {
639        assert!(is_quarantine_denied("shell_bash"));
640        assert!(is_quarantine_denied("mcp_shell_bash"));
641    }
642
643    #[test]
644    fn is_quarantine_denied_suffix_mcp_fetch() {
645        assert!(is_quarantine_denied("http_fetch"));
646        // "server_prefetch" ends with "_prefetch", not "_fetch"
647        assert!(!is_quarantine_denied("server_prefetch"));
648    }
649
650    #[test]
651    fn is_quarantine_denied_suffix_mcp_memory_save() {
652        assert!(is_quarantine_denied("server_memory_save"));
653        // "_save" alone does NOT match the multi-word entry "memory_save"
654        assert!(!is_quarantine_denied("server_save"));
655    }
656
657    #[test]
658    fn is_quarantine_denied_suffix_mcp_delete_path() {
659        assert!(is_quarantine_denied("fs_delete_path"));
660        // "fs_not_delete_path" ends with "_delete_path" as well — suffix check is correct
661        assert!(is_quarantine_denied("fs_not_delete_path"));
662    }
663
664    #[test]
665    fn is_quarantine_denied_substring_not_suffix() {
666        // "write_log" ends with "_log", NOT "_write" — must NOT be denied
667        assert!(!is_quarantine_denied("write_log"));
668    }
669
670    #[test]
671    fn is_quarantine_denied_read_only_tools_allowed() {
672        assert!(!is_quarantine_denied("filesystem_read_file"));
673        assert!(!is_quarantine_denied("filesystem_list_dir"));
674        assert!(!is_quarantine_denied("read"));
675        assert!(!is_quarantine_denied("file_read"));
676    }
677
678    #[tokio::test]
679    async fn quarantined_denies_mcp_write_tool() {
680        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
681        gate.set_effective_trust(SkillTrustLevel::Quarantined);
682
683        let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
684        assert!(matches!(result, Err(ToolError::Blocked { .. })));
685    }
686
687    #[tokio::test]
688    async fn quarantined_allows_mcp_read_file() {
689        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
690        let gate = TrustGateExecutor::new(MockExecutor, policy);
691        gate.set_effective_trust(SkillTrustLevel::Quarantined);
692
693        let result = gate
694            .execute_tool_call(&make_call("filesystem_read_file"))
695            .await;
696        assert!(result.is_ok());
697    }
698
699    #[tokio::test]
700    async fn quarantined_denies_mcp_bash_tool() {
701        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
702        gate.set_effective_trust(SkillTrustLevel::Quarantined);
703
704        let result = gate.execute_tool_call(&make_call("shell_bash")).await;
705        assert!(matches!(result, Err(ToolError::Blocked { .. })));
706    }
707
708    #[tokio::test]
709    async fn quarantined_denies_mcp_memory_save() {
710        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
711        gate.set_effective_trust(SkillTrustLevel::Quarantined);
712
713        let result = gate
714            .execute_tool_call(&make_call("server_memory_save"))
715            .await;
716        assert!(matches!(result, Err(ToolError::Blocked { .. })));
717    }
718
719    #[tokio::test]
720    async fn quarantined_denies_mcp_confirmed_path() {
721        // execute_tool_call_confirmed also enforces quarantine via is_quarantine_denied
722        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
723        gate.set_effective_trust(SkillTrustLevel::Quarantined);
724
725        let result = gate
726            .execute_tool_call_confirmed(&make_call("filesystem_write"))
727            .await;
728        assert!(matches!(result, Err(ToolError::Blocked { .. })));
729    }
730
731    // mcp_tool_ids registry tests
732
733    fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
734        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
735        let handle = gate.mcp_tool_ids_handle();
736        let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
737        *handle.write() = set;
738        gate
739    }
740
741    #[tokio::test]
742    async fn quarantined_denies_registered_mcp_tool_novel_name() {
743        // "github_run_command" has no QUARANTINE_DENIED suffix match, but is registered as MCP.
744        let gate = gate_with_mcp_ids(&["github_run_command"]);
745        gate.set_effective_trust(SkillTrustLevel::Quarantined);
746
747        let result = gate
748            .execute_tool_call(&make_call("github_run_command"))
749            .await;
750        assert!(matches!(result, Err(ToolError::Blocked { .. })));
751    }
752
753    #[tokio::test]
754    async fn quarantined_denies_registered_mcp_tool_execute() {
755        // "shell_execute" — no suffix match on "execute", but registered as MCP.
756        let gate = gate_with_mcp_ids(&["shell_execute"]);
757        gate.set_effective_trust(SkillTrustLevel::Quarantined);
758
759        let result = gate.execute_tool_call(&make_call("shell_execute")).await;
760        assert!(matches!(result, Err(ToolError::Blocked { .. })));
761    }
762
763    #[tokio::test]
764    async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
765        // Tool not in MCP set and not in QUARANTINE_DENIED — allowed.
766        let gate = gate_with_mcp_ids(&["other_tool"]);
767        gate.set_effective_trust(SkillTrustLevel::Quarantined);
768
769        let result = gate.execute_tool_call(&make_call("read")).await;
770        assert!(result.is_ok());
771    }
772
773    #[tokio::test]
774    async fn trusted_allows_registered_mcp_tool() {
775        // At Trusted level, MCP registry check must NOT fire.
776        let gate = gate_with_mcp_ids(&["github_run_command"]);
777        gate.set_effective_trust(SkillTrustLevel::Trusted);
778
779        let result = gate
780            .execute_tool_call(&make_call("github_run_command"))
781            .await;
782        assert!(result.is_ok());
783    }
784
785    #[tokio::test]
786    async fn quarantined_denies_mcp_tool_via_confirmed_path() {
787        // execute_tool_call_confirmed must also check the MCP registry.
788        let gate = gate_with_mcp_ids(&["docker_container_exec"]);
789        gate.set_effective_trust(SkillTrustLevel::Quarantined);
790
791        let result = gate
792            .execute_tool_call_confirmed(&make_call("docker_container_exec"))
793            .await;
794        assert!(matches!(result, Err(ToolError::Blocked { .. })));
795    }
796
797    #[test]
798    fn mcp_tool_ids_handle_shared_arc() {
799        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
800        let handle = gate.mcp_tool_ids_handle();
801        handle.write().insert("test_tool".to_owned());
802        assert!(gate.is_mcp_tool("test_tool"));
803        assert!(!gate.is_mcp_tool("other_tool"));
804    }
805
806    // M9: document that the suffix matcher applies to MCP tools ending with
807    // `_invoke_skill` or `_load_skill`. Future MCP tool authors should be aware.
808    #[test]
809    fn invoke_skill_and_load_skill_suffix_match_is_intentional() {
810        // Exact-match branch: native tool IDs are denied.
811        assert!(is_quarantine_denied("invoke_skill"));
812        assert!(is_quarantine_denied("load_skill"));
813        // Suffix-match branch: hypothetical MCP-prefixed versions are also denied.
814        // This is intentional — prevents a renamed MCP wrapper from bypassing the gate.
815        assert!(is_quarantine_denied("foo_invoke_skill"));
816        assert!(is_quarantine_denied("foo_load_skill"));
817    }
818}