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