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