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
27pub(crate) fn 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
33/// Builds the denial message for a Quarantined-trust block.
34///
35/// `active_skills` is the turn's full active-skill list (`ToolCall::skill_name`), not just
36/// the specific skill(s) whose trust caused the fold — `TrustGateExecutor` only tracks the
37/// already-folded `effective_trust`, not per-skill levels, so it cannot name exactly which
38/// skill(s) are Quarantined, nor whether `tool_id`'s own target skill (e.g. `invoke_skill`'s
39/// `skill_name` param) is among them. Naming the turn's active skill set instead of flatly
40/// blaming `tool_id` resolves the misattribution from #5729 without asserting anything the
41/// gate cannot verify: this denial means the turn's *combined* trust floor is quarantined
42/// (weakest-link policy, see `assembly.rs` and this module's doc comment) — it may or may not
43/// be about the specific tool/skill targeted by this call.
44pub(crate) fn quarantine_denial_message(tool_id: &str, active_skills: &[String]) -> String {
45    if active_skills.is_empty() {
46        format!("{tool_id} denied (trust=quarantined)")
47    } else {
48        format!(
49            "{tool_id} denied: this turn's active skill set {active_skills:?} has a combined \
50             trust floor of quarantined (weakest-link policy over all co-active skills this \
51             turn; this reflects the turn's overall trust floor and may not be about the \
52             specific tool/skill you targeted)"
53        )
54    }
55}
56
57pub(crate) fn trust_to_u8(level: SkillTrustLevel) -> u8 {
58    match level {
59        SkillTrustLevel::Trusted => 0,
60        SkillTrustLevel::Verified => 1,
61        SkillTrustLevel::Quarantined => 2,
62        _ => 3,
63    }
64}
65
66pub(crate) fn u8_to_trust(v: u8) -> SkillTrustLevel {
67    match v {
68        0 => SkillTrustLevel::Trusted,
69        1 => SkillTrustLevel::Verified,
70        2 => SkillTrustLevel::Quarantined,
71        _ => SkillTrustLevel::Blocked,
72    }
73}
74
75/// Wraps an inner `ToolExecutor` and applies trust-level permission overlays.
76pub struct TrustGateExecutor<T: ToolExecutor> {
77    inner: T,
78    policy: PermissionPolicy,
79    effective_trust: AtomicU8,
80    /// Sanitized IDs of all registered MCP tools. When a Quarantined skill is
81    /// active, any tool whose ID appears in this set is denied — regardless of
82    /// whether its name matches `QUARANTINE_DENIED`. Populated at startup by
83    /// calling `set_mcp_tool_ids` after MCP servers connect.
84    mcp_tool_ids: Arc<RwLock<HashSet<String>>>,
85}
86
87impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for TrustGateExecutor<T> {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("TrustGateExecutor")
90            .field("inner", &self.inner)
91            .field("policy", &self.policy)
92            .field("effective_trust", &self.effective_trust())
93            .field("mcp_tool_ids", &self.mcp_tool_ids)
94            .finish()
95    }
96}
97
98impl<T: ToolExecutor> TrustGateExecutor<T> {
99    #[must_use]
100    pub fn new(inner: T, policy: PermissionPolicy) -> Self {
101        Self {
102            inner,
103            policy,
104            effective_trust: AtomicU8::new(trust_to_u8(SkillTrustLevel::Trusted)),
105            mcp_tool_ids: Arc::new(RwLock::new(HashSet::new())),
106        }
107    }
108
109    /// Returns the shared MCP tool ID set so the caller can populate it after
110    /// MCP servers have connected (and after `TrustGateExecutor` has been wrapped
111    /// in a `DynExecutor`).
112    #[must_use]
113    pub fn mcp_tool_ids_handle(&self) -> Arc<RwLock<HashSet<String>>> {
114        Arc::clone(&self.mcp_tool_ids)
115    }
116
117    pub fn set_effective_trust(&self, level: SkillTrustLevel) {
118        self.effective_trust
119            .store(trust_to_u8(level), Ordering::Relaxed);
120    }
121
122    #[must_use]
123    pub fn effective_trust(&self) -> SkillTrustLevel {
124        u8_to_trust(self.effective_trust.load(Ordering::Relaxed))
125    }
126
127    fn is_mcp_tool(&self, tool_id: &str) -> bool {
128        self.mcp_tool_ids.read().contains(tool_id)
129    }
130
131    /// Enforces per-call trust policy.
132    ///
133    /// `effective_trust` (see [`set_effective_trust`](Self::set_effective_trust)) is a single
134    /// value folded via `SkillTrustLevel::min_trust` across ALL skills active in the current
135    /// turn — computed in `zeph_core::agent::context::assembly`. This is a deliberate
136    /// weakest-link policy: if ANY skill active this turn is Quarantined,
137    /// [`QUARANTINE_DENIED`] tools (including `invoke_skill`/`load_skill`) are denied for the
138    /// WHOLE turn, regardless of which specific skill/tool a call targets — this guards
139    /// against a Quarantined (potentially prompt-injected) skill's content steering the model
140    /// into invoking other tools/skills as a side channel. See #5729 for the resulting UX gap
141    /// (an unrelated, non-quarantined skill's own `invoke_skill` call is also denied) and why
142    /// the policy itself is intentionally kept — `active_skills` is used only to make the
143    /// denial message name the turn's active skill set instead of misattributing the block to
144    /// `tool_id` itself.
145    fn check_trust(
146        &self,
147        tool_id: &str,
148        input: &str,
149        active_skills: &[String],
150    ) -> Result<(), ToolError> {
151        match self.effective_trust() {
152            SkillTrustLevel::Blocked => {
153                return Err(ToolError::Blocked {
154                    command: "all tools blocked (trust=blocked)".to_owned(),
155                });
156            }
157            SkillTrustLevel::Quarantined
158                if is_quarantine_denied(tool_id) || self.is_mcp_tool(tool_id) =>
159            {
160                return Err(ToolError::Blocked {
161                    command: quarantine_denial_message(tool_id, active_skills),
162                });
163            }
164            _ => {}
165        }
166
167        // PermissionPolicy was designed for the bash tool. In Supervised mode, tools
168        // without explicit rules default to Ask, which incorrectly blocks MCP/LSP tools
169        // and native read-only tools (both are already categorized elsewhere: MCP/LSP
170        // tools via `mcp_tool_ids`, read-only native tools via `permissions::READONLY_TOOLS`).
171        // Skip the policy check only for tools that fall into one of those two known-safe
172        // categories — trust-level enforcement above is sufficient for them. Any other
173        // unconfigured tool (e.g. `diagnostics`, which runs cargo check/clippy and can
174        // execute arbitrary code via build.rs/proc-macros) falls through to the Ask
175        // default below; see #5575.
176        // ReadOnly mode is excluded: its allowlist is enforced inside policy.check().
177        if self.policy.autonomy_level() == AutonomyLevel::Supervised
178            && self.policy.rules().get(tool_id).is_none()
179            && (self.is_mcp_tool(tool_id) || crate::permissions::is_readonly_tool(tool_id))
180        {
181            return Ok(());
182        }
183
184        match self.policy.check(tool_id, input) {
185            PermissionAction::Allow => Ok(()),
186            PermissionAction::Ask => Err(ToolError::ConfirmationRequired {
187                command: input.to_owned(),
188            }),
189            _ => Err(ToolError::Blocked {
190                command: input.to_owned(),
191            }),
192        }
193    }
194}
195
196impl<T: ToolExecutor> ToolExecutor for TrustGateExecutor<T> {
197    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
198        // The legacy fenced-block path does not provide a tool_id, so QUARANTINE_DENIED
199        // cannot be applied selectively. Block entirely for Quarantined to match the
200        // conservative posture: unknown tool identity = deny.
201        match self.effective_trust() {
202            SkillTrustLevel::Blocked | SkillTrustLevel::Quarantined => {
203                return Err(ToolError::Blocked {
204                    command: format!(
205                        "tool execution denied (trust={})",
206                        format!("{:?}", self.effective_trust()).to_lowercase()
207                    ),
208                });
209            }
210            _ => {}
211        }
212        self.inner.execute(response).await
213    }
214
215    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
216        // Same rationale as execute(): no tool_id available for QUARANTINE_DENIED check.
217        match self.effective_trust() {
218            SkillTrustLevel::Blocked | SkillTrustLevel::Quarantined => {
219                return Err(ToolError::Blocked {
220                    command: format!(
221                        "tool execution denied (trust={})",
222                        format!("{:?}", self.effective_trust()).to_lowercase()
223                    ),
224                });
225            }
226            _ => {}
227        }
228        self.inner.execute_confirmed(response).await
229    }
230
231    fn tool_definitions(&self) -> Vec<ToolDef> {
232        self.inner.tool_definitions()
233    }
234
235    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
236        let input = call
237            .params
238            .get("command")
239            .or_else(|| call.params.get("file_path"))
240            .or_else(|| call.params.get("query"))
241            .or_else(|| call.params.get("url"))
242            .or_else(|| call.params.get("uri"))
243            .and_then(|v| v.as_str())
244            .unwrap_or("");
245        self.check_trust(
246            call.tool_id.as_str(),
247            input,
248            call.skill_name.as_deref().unwrap_or(&[]),
249        )?;
250        self.inner.execute_tool_call(call).await
251    }
252
253    async fn execute_tool_call_confirmed(
254        &self,
255        call: &ToolCall,
256    ) -> Result<Option<ToolOutput>, ToolError> {
257        // Bypass check_trust: caller already obtained user approval.
258        // Still enforce Blocked/Quarantined trust level constraints. This match intentionally
259        // mirrors check_trust's Blocked/Quarantined branches above — keep the two in sync.
260        match self.effective_trust() {
261            SkillTrustLevel::Blocked => {
262                return Err(ToolError::Blocked {
263                    command: "all tools blocked (trust=blocked)".to_owned(),
264                });
265            }
266            SkillTrustLevel::Quarantined
267                if is_quarantine_denied(call.tool_id.as_str())
268                    || self.is_mcp_tool(call.tool_id.as_str()) =>
269            {
270                return Err(ToolError::Blocked {
271                    command: quarantine_denial_message(
272                        call.tool_id.as_str(),
273                        call.skill_name.as_deref().unwrap_or(&[]),
274                    ),
275                });
276            }
277            _ => {}
278        }
279        self.inner.execute_tool_call_confirmed(call).await
280    }
281
282    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
283        self.inner.set_skill_env(env);
284    }
285
286    fn is_tool_retryable(&self, tool_id: &str) -> bool {
287        self.inner.is_tool_retryable(tool_id)
288    }
289
290    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
291        self.inner.is_tool_speculatable(tool_id)
292    }
293
294    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
295        self.effective_trust
296            .store(trust_to_u8(level), Ordering::Relaxed);
297    }
298
299    /// Returns `true` when the current policy would require confirmation for `call`.
300    ///
301    /// Mirrors the decision in [`execute_tool_call`](Self::execute_tool_call) without
302    /// executing the tool. The speculative engine calls this to skip dispatch for tools
303    /// that require user approval.
304    fn requires_confirmation(&self, call: &crate::executor::ToolCall) -> bool {
305        let input = call
306            .params
307            .get("command")
308            .or_else(|| call.params.get("file_path"))
309            .or_else(|| call.params.get("query"))
310            .or_else(|| call.params.get("url"))
311            .or_else(|| call.params.get("uri"))
312            .and_then(|v| v.as_str())
313            .unwrap_or("");
314        matches!(
315            self.check_trust(
316                call.tool_id.as_str(),
317                input,
318                call.skill_name.as_deref().unwrap_or(&[]),
319            ),
320            Err(ToolError::ConfirmationRequired { .. })
321        )
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use std::assert_matches;
329
330    #[derive(Debug)]
331    struct MockExecutor;
332    impl ToolExecutor for MockExecutor {
333        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
334            Ok(None)
335        }
336        async fn execute_tool_call(
337            &self,
338            call: &ToolCall,
339        ) -> Result<Option<ToolOutput>, ToolError> {
340            Ok(Some(ToolOutput {
341                tool_name: call.tool_id.clone(),
342                summary: "ok".into(),
343                blocks_executed: 1,
344                filter_stats: None,
345                diff: None,
346                streamed: false,
347                terminal_id: None,
348                locations: None,
349                raw_response: None,
350                claim_source: None,
351            }))
352        }
353    }
354
355    fn make_call(tool_id: &str) -> ToolCall {
356        ToolCall {
357            tool_id: tool_id.into(),
358            params: serde_json::Map::new(),
359            caller_id: None,
360            context: None,
361
362            tool_call_id: String::new(),
363            skill_name: None,
364        }
365    }
366
367    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
368        let mut params = serde_json::Map::new();
369        params.insert("command".into(), serde_json::Value::String(cmd.into()));
370        ToolCall {
371            tool_id: tool_id.into(),
372            params,
373            caller_id: None,
374            context: None,
375
376            tool_call_id: String::new(),
377            skill_name: None,
378        }
379    }
380
381    fn make_call_with_skills(tool_id: &str, skills: &[&str]) -> ToolCall {
382        ToolCall {
383            tool_id: tool_id.into(),
384            params: serde_json::Map::new(),
385            caller_id: None,
386            context: None,
387
388            tool_call_id: String::new(),
389            skill_name: Some(skills.iter().map(ToString::to_string).collect()),
390        }
391    }
392
393    fn blocked_command(result: Result<Option<ToolOutput>, ToolError>) -> String {
394        match result {
395            Err(ToolError::Blocked { command }) => command,
396            other => panic!("expected Err(ToolError::Blocked {{ .. }}), got {other:?}"),
397        }
398    }
399
400    #[tokio::test]
401    async fn supervised_readonly_native_tool_without_rule_allowed() {
402        // "read" is a native read-only tool (permissions::READONLY_TOOLS) — it must
403        // still bypass the Ask default in Supervised mode even without an explicit rule.
404        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
405        gate.set_effective_trust(SkillTrustLevel::Trusted);
406
407        let result = gate.execute_tool_call(&make_call("read")).await;
408        assert!(result.is_ok());
409    }
410
411    /// Regression test for #5575: `bash` has no explicit policy rule and is neither an
412    /// MCP tool nor a native read-only tool, so it must NOT bypass confirmation in
413    /// Supervised mode — the prior blanket skip incorrectly allowed this.
414    #[tokio::test]
415    async fn supervised_unconfigured_non_mcp_non_readonly_tool_requires_confirmation() {
416        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
417        gate.set_effective_trust(SkillTrustLevel::Trusted);
418
419        let result = gate.execute_tool_call(&make_call("bash")).await;
420        assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
421    }
422
423    /// Regression test for #5575: `diagnostics` runs `cargo check`/`cargo clippy`, which
424    /// executes arbitrary code via `build.rs` scripts and proc-macros — it must require
425    /// confirmation in Supervised mode when no explicit rule is configured, not bypass it
426    /// via the (now-removed) blanket "no rule => Ok" skip.
427    #[tokio::test]
428    async fn supervised_unconfigured_diagnostics_requires_confirmation() {
429        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
430        gate.set_effective_trust(SkillTrustLevel::Trusted);
431
432        let result = gate.execute_tool_call(&make_call("diagnostics")).await;
433        assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
434    }
435
436    #[tokio::test]
437    async fn quarantined_denies_bash() {
438        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
439        gate.set_effective_trust(SkillTrustLevel::Quarantined);
440
441        let result = gate.execute_tool_call(&make_call("bash")).await;
442        assert_matches!(result, Err(ToolError::Blocked { .. }));
443    }
444
445    #[tokio::test]
446    async fn quarantined_denies_write() {
447        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
448        gate.set_effective_trust(SkillTrustLevel::Quarantined);
449
450        let result = gate.execute_tool_call(&make_call("write")).await;
451        assert_matches!(result, Err(ToolError::Blocked { .. }));
452    }
453
454    #[tokio::test]
455    async fn quarantined_denies_edit() {
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("edit")).await;
460        assert_matches!(result, Err(ToolError::Blocked { .. }));
461    }
462
463    #[tokio::test]
464    async fn quarantined_denies_delete_path() {
465        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
466        gate.set_effective_trust(SkillTrustLevel::Quarantined);
467
468        let result = gate.execute_tool_call(&make_call("delete_path")).await;
469        assert_matches!(result, Err(ToolError::Blocked { .. }));
470    }
471
472    #[tokio::test]
473    async fn quarantined_denies_fetch() {
474        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
475        gate.set_effective_trust(SkillTrustLevel::Quarantined);
476
477        let result = gate.execute_tool_call(&make_call("fetch")).await;
478        assert_matches!(result, Err(ToolError::Blocked { .. }));
479    }
480
481    #[tokio::test]
482    async fn quarantined_denies_memory_save() {
483        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
484        gate.set_effective_trust(SkillTrustLevel::Quarantined);
485
486        let result = gate.execute_tool_call(&make_call("memory_save")).await;
487        assert_matches!(result, Err(ToolError::Blocked { .. }));
488    }
489
490    /// Regression test for #5433: `diagnostics` runs `cargo check`/`cargo clippy`, which
491    /// executes arbitrary code via `build.rs` scripts and proc-macros in the target
492    /// workspace — equivalent to `bash` for security purposes. Now that #5433 wires
493    /// `DiagnosticsExecutor` into the live, `TrustGateExecutor`-gated composite chain, it
494    /// must be quarantine-denied like `bash`.
495    #[tokio::test]
496    async fn quarantined_denies_diagnostics() {
497        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
498        gate.set_effective_trust(SkillTrustLevel::Quarantined);
499
500        let result = gate.execute_tool_call(&make_call("diagnostics")).await;
501        assert_matches!(result, Err(ToolError::Blocked { .. }));
502    }
503
504    #[tokio::test]
505    async fn quarantined_allows_read() {
506        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
507        let gate = TrustGateExecutor::new(MockExecutor, policy);
508        gate.set_effective_trust(SkillTrustLevel::Quarantined);
509
510        // "read" (file read) is not in QUARANTINE_DENIED — should be allowed
511        let result = gate.execute_tool_call(&make_call("read")).await;
512        assert!(result.is_ok());
513    }
514
515    #[tokio::test]
516    async fn quarantined_allows_file_read() {
517        // "file_read" is not in the quarantine-denied list, but (unlike "read") it is also
518        // not in `permissions::READONLY_TOOLS`, so an explicit Allow rule is required here
519        // to isolate this test from the Supervised-mode Ask default (see #5575) and keep it
520        // focused on quarantine-denial behavior only.
521        let mut rules = std::collections::HashMap::new();
522        rules.insert(
523            "file_read".to_owned(),
524            vec![crate::permissions::PermissionRule {
525                pattern: "*".to_owned(),
526                action: PermissionAction::Allow,
527            }],
528        );
529        let policy = crate::permissions::PermissionPolicy::new(rules);
530        let gate = TrustGateExecutor::new(MockExecutor, policy);
531        gate.set_effective_trust(SkillTrustLevel::Quarantined);
532
533        let result = gate.execute_tool_call(&make_call("file_read")).await;
534        // file_read is not in quarantine denied list, and the explicit rule allows it => Ok
535        assert!(result.is_ok());
536    }
537
538    #[tokio::test]
539    async fn blocked_denies_everything() {
540        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
541        gate.set_effective_trust(SkillTrustLevel::Blocked);
542
543        let result = gate.execute_tool_call(&make_call("file_read")).await;
544        assert_matches!(result, Err(ToolError::Blocked { .. }));
545    }
546
547    #[tokio::test]
548    async fn policy_deny_overrides_trust() {
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 rm"))
555            .await;
556        assert_matches!(result, Err(ToolError::Blocked { .. }));
557    }
558
559    #[tokio::test]
560    async fn blocked_denies_execute() {
561        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
562        gate.set_effective_trust(SkillTrustLevel::Blocked);
563
564        let result = gate.execute("some response").await;
565        assert_matches!(result, Err(ToolError::Blocked { .. }));
566    }
567
568    #[tokio::test]
569    async fn blocked_denies_execute_confirmed() {
570        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
571        gate.set_effective_trust(SkillTrustLevel::Blocked);
572
573        let result = gate.execute_confirmed("some response").await;
574        assert_matches!(result, Err(ToolError::Blocked { .. }));
575    }
576
577    #[tokio::test]
578    async fn trusted_allows_execute() {
579        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
580        gate.set_effective_trust(SkillTrustLevel::Trusted);
581
582        let result = gate.execute("some response").await;
583        assert!(result.is_ok());
584    }
585
586    #[tokio::test]
587    async fn verified_with_allow_policy_succeeds() {
588        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
589        let gate = TrustGateExecutor::new(MockExecutor, policy);
590        gate.set_effective_trust(SkillTrustLevel::Verified);
591
592        let result = gate
593            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
594            .await
595            .unwrap();
596        assert!(result.is_some());
597    }
598
599    #[tokio::test]
600    async fn quarantined_denies_web_scrape() {
601        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
602        gate.set_effective_trust(SkillTrustLevel::Quarantined);
603
604        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
605        assert_matches!(result, Err(ToolError::Blocked { .. }));
606    }
607
608    /// Regression test for #5729: the denial message must name the turn's actual co-active
609    /// skill set instead of implying `invoke_skill` itself is the untrusted party. The gate
610    /// behavior (deny) is unchanged — only the message wording is under test here.
611    #[tokio::test]
612    async fn quarantined_denial_message_names_active_skills_not_target_tool() {
613        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
614        gate.set_effective_trust(SkillTrustLevel::Quarantined);
615
616        let call =
617            make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
618        let result = gate.execute_tool_call(&call).await;
619        let message = blocked_command(result);
620
621        assert!(
622            message.contains("disk-usage") && message.contains("persona-customer-support"),
623            "message should name the actual active skills, got: {message}"
624        );
625        assert_ne!(
626            message, "invoke_skill denied (trust=quarantined)",
627            "message must not read as if invoke_skill itself is the untrusted party"
628        );
629    }
630
631    /// Regression test for #5729: when no active skills are recorded on the call
632    /// (`skill_name: None`), the denial message must remain exactly the pre-fix format —
633    /// this guards backward compatibility for all pre-existing tests in this module, which
634    /// all use `make_call`/`make_call_with_cmd` (both hardcode `skill_name: None`).
635    #[tokio::test]
636    async fn quarantined_denial_message_unchanged_when_no_active_skills() {
637        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
638        gate.set_effective_trust(SkillTrustLevel::Quarantined);
639
640        let result = gate.execute_tool_call(&make_call("invoke_skill")).await;
641        let message = blocked_command(result);
642
643        assert_eq!(message, "invoke_skill denied (trust=quarantined)");
644    }
645
646    /// Same as `quarantined_denial_message_unchanged_when_no_active_skills`, but with an
647    /// explicit empty skill list rather than `None` — both must produce the old message.
648    #[tokio::test]
649    async fn quarantined_denial_message_unchanged_when_active_skills_empty() {
650        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
651        gate.set_effective_trust(SkillTrustLevel::Quarantined);
652
653        let result = gate
654            .execute_tool_call(&make_call_with_skills("invoke_skill", &[]))
655            .await;
656        let message = blocked_command(result);
657
658        assert_eq!(message, "invoke_skill denied (trust=quarantined)");
659    }
660
661    /// Regression test for #5729 via the `execute_tool_call_confirmed` path, which has its
662    /// own duplicate inline Quarantined check rather than routing through `check_trust`.
663    #[tokio::test]
664    async fn quarantined_denial_message_names_active_skills_confirmed_path() {
665        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
666        gate.set_effective_trust(SkillTrustLevel::Quarantined);
667
668        let call =
669            make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
670        let result = gate.execute_tool_call_confirmed(&call).await;
671        let message = blocked_command(result);
672
673        assert!(
674            message.contains("disk-usage") && message.contains("persona-customer-support"),
675            "confirmed path message should name the actual active skills, got: {message}"
676        );
677        assert_ne!(
678            message, "invoke_skill denied (trust=quarantined)",
679            "confirmed path message must not read as if invoke_skill itself is untrusted"
680        );
681    }
682
683    /// Regression test for #5729: the message fix must apply uniformly to any
684    /// `QUARANTINE_DENIED` tool, not just `invoke_skill` — verified here with `bash`.
685    #[tokio::test]
686    async fn quarantined_denial_message_names_active_skills_for_non_skill_tool() {
687        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
688        gate.set_effective_trust(SkillTrustLevel::Quarantined);
689
690        let call = make_call_with_skills("bash", &["disk-usage", "persona-customer-support"]);
691        let result = gate.execute_tool_call(&call).await;
692        let message = blocked_command(result);
693
694        assert!(
695            message.contains("disk-usage") && message.contains("persona-customer-support"),
696            "message for a non-skill tool should also name the active skills, got: {message}"
697        );
698        assert_ne!(message, "bash denied (trust=quarantined)");
699    }
700
701    #[derive(Debug)]
702    struct EnvCapture {
703        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
704    }
705    impl EnvCapture {
706        fn new() -> Self {
707            Self {
708                captured: std::sync::Mutex::new(None),
709            }
710        }
711    }
712    impl ToolExecutor for EnvCapture {
713        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
714            Ok(None)
715        }
716        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
717            Ok(None)
718        }
719        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
720            *self.captured.lock().unwrap() = env;
721        }
722    }
723
724    #[test]
725    fn is_tool_retryable_delegated_to_inner() {
726        #[derive(Debug)]
727        struct RetryableExecutor;
728        impl ToolExecutor for RetryableExecutor {
729            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
730                Ok(None)
731            }
732            async fn execute_tool_call(
733                &self,
734                _: &ToolCall,
735            ) -> Result<Option<ToolOutput>, ToolError> {
736                Ok(None)
737            }
738            fn is_tool_retryable(&self, tool_id: &str) -> bool {
739                tool_id == "fetch"
740            }
741        }
742        let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
743        assert!(gate.is_tool_retryable("fetch"));
744        assert!(!gate.is_tool_retryable("bash"));
745    }
746
747    #[test]
748    fn set_skill_env_forwarded_to_inner() {
749        let inner = EnvCapture::new();
750        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
751
752        let mut env = std::collections::HashMap::new();
753        env.insert("MY_VAR".to_owned(), "42".to_owned());
754        gate.set_skill_env(Some(env.clone()));
755
756        let captured = gate.inner.captured.lock().unwrap();
757        assert_eq!(*captured, Some(env));
758    }
759
760    #[tokio::test]
761    async fn mcp_tool_supervised_no_rules_allows() {
762        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok.
763        // Registered via `mcp_tool_ids_handle` so `is_mcp_tool` recognizes it as genuinely
764        // MCP-sourced — see #5575 (the skip is no longer a blanket "no rule => Ok").
765        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
766        let gate = TrustGateExecutor::new(MockExecutor, policy);
767        gate.set_effective_trust(SkillTrustLevel::Trusted);
768        gate.mcp_tool_ids_handle()
769            .write()
770            .insert("mcp_filesystem__read_file".to_owned());
771
772        let mut params = serde_json::Map::new();
773        params.insert(
774            "file_path".into(),
775            serde_json::Value::String("/tmp/test.txt".into()),
776        );
777        let call = ToolCall {
778            tool_id: "mcp_filesystem__read_file".into(),
779            params,
780            caller_id: None,
781            context: None,
782
783            tool_call_id: String::new(),
784            skill_name: None,
785        };
786        let result = gate.execute_tool_call(&call).await;
787        assert!(
788            result.is_ok(),
789            "MCP tool should be allowed when no rules exist"
790        );
791    }
792
793    #[tokio::test]
794    async fn bash_with_explicit_deny_rule_blocked() {
795        // Bash with explicit Deny rule => Err(ToolCallBlocked)
796        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
797        let gate = TrustGateExecutor::new(MockExecutor, policy);
798        gate.set_effective_trust(SkillTrustLevel::Trusted);
799
800        let result = gate
801            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
802            .await;
803        assert!(
804            matches!(result, Err(ToolError::Blocked { .. })),
805            "bash with explicit deny rule should be blocked"
806        );
807    }
808
809    #[tokio::test]
810    async fn bash_with_explicit_allow_rule_succeeds() {
811        // Tool with explicit Allow rules => Ok
812        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
813        let gate = TrustGateExecutor::new(MockExecutor, policy);
814        gate.set_effective_trust(SkillTrustLevel::Trusted);
815
816        let result = gate
817            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
818            .await;
819        assert!(
820            result.is_ok(),
821            "bash with explicit allow rule should succeed"
822        );
823    }
824
825    #[tokio::test]
826    async fn readonly_denies_mcp_tool_not_in_allowlist() {
827        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
828        let policy =
829            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
830        let gate = TrustGateExecutor::new(MockExecutor, policy);
831        gate.set_effective_trust(SkillTrustLevel::Trusted);
832
833        let result = gate
834            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
835            .await;
836        assert!(
837            matches!(result, Err(ToolError::Blocked { .. })),
838            "ReadOnly mode must deny non-allowlisted tools"
839        );
840    }
841
842    #[test]
843    fn set_effective_trust_interior_mutability() {
844        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
845        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
846
847        gate.set_effective_trust(SkillTrustLevel::Quarantined);
848        assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
849
850        gate.set_effective_trust(SkillTrustLevel::Blocked);
851        assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
852
853        gate.set_effective_trust(SkillTrustLevel::Trusted);
854        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
855    }
856
857    // is_quarantine_denied unit tests
858
859    #[test]
860    fn is_quarantine_denied_exact_match() {
861        assert!(is_quarantine_denied("bash"));
862        assert!(is_quarantine_denied("write"));
863        assert!(is_quarantine_denied("fetch"));
864        assert!(is_quarantine_denied("memory_save"));
865        assert!(is_quarantine_denied("delete_path"));
866        assert!(is_quarantine_denied("create_directory"));
867        assert!(is_quarantine_denied("diagnostics"));
868    }
869
870    #[test]
871    fn is_quarantine_denied_suffix_match_mcp_write() {
872        // "filesystem_write" ends with "_write" -> denied
873        assert!(is_quarantine_denied("filesystem_write"));
874        // "filesystem_write_file" ends with "_file", not "_write" -> NOT denied
875        assert!(!is_quarantine_denied("filesystem_write_file"));
876    }
877
878    #[test]
879    fn is_quarantine_denied_suffix_mcp_bash() {
880        assert!(is_quarantine_denied("shell_bash"));
881        assert!(is_quarantine_denied("mcp_shell_bash"));
882    }
883
884    #[test]
885    fn is_quarantine_denied_suffix_mcp_fetch() {
886        assert!(is_quarantine_denied("http_fetch"));
887        // "server_prefetch" ends with "_prefetch", not "_fetch"
888        assert!(!is_quarantine_denied("server_prefetch"));
889    }
890
891    #[test]
892    fn is_quarantine_denied_suffix_mcp_memory_save() {
893        assert!(is_quarantine_denied("server_memory_save"));
894        // "_save" alone does NOT match the multi-word entry "memory_save"
895        assert!(!is_quarantine_denied("server_save"));
896    }
897
898    #[test]
899    fn is_quarantine_denied_suffix_mcp_delete_path() {
900        assert!(is_quarantine_denied("fs_delete_path"));
901        // "fs_not_delete_path" ends with "_delete_path" as well — suffix check is correct
902        assert!(is_quarantine_denied("fs_not_delete_path"));
903    }
904
905    #[test]
906    fn is_quarantine_denied_substring_not_suffix() {
907        // "write_log" ends with "_log", NOT "_write" — must NOT be denied
908        assert!(!is_quarantine_denied("write_log"));
909    }
910
911    #[test]
912    fn is_quarantine_denied_read_only_tools_allowed() {
913        assert!(!is_quarantine_denied("filesystem_read_file"));
914        assert!(!is_quarantine_denied("filesystem_list_dir"));
915        assert!(!is_quarantine_denied("read"));
916        assert!(!is_quarantine_denied("file_read"));
917    }
918
919    #[tokio::test]
920    async fn quarantined_denies_mcp_write_tool() {
921        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
922        gate.set_effective_trust(SkillTrustLevel::Quarantined);
923
924        let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
925        assert_matches!(result, Err(ToolError::Blocked { .. }));
926    }
927
928    #[tokio::test]
929    async fn quarantined_allows_mcp_read_file() {
930        // Deliberately NOT registered in `mcp_tool_ids`: a tool registered there is
931        // denied outright under Quarantine regardless of read/write (see
932        // `mcp_tool_ids` field docs), so this test isolates a different case — a
933        // tool that merely looks MCP-like by name but isn't quarantine-denied by
934        // `is_quarantine_denied`. An explicit Allow rule keeps it decoupled from the
935        // Supervised-mode Ask default for unconfigured tools (see #5575).
936        let mut rules = std::collections::HashMap::new();
937        rules.insert(
938            "filesystem_read_file".to_owned(),
939            vec![crate::permissions::PermissionRule {
940                pattern: "*".to_owned(),
941                action: PermissionAction::Allow,
942            }],
943        );
944        let policy = crate::permissions::PermissionPolicy::new(rules);
945        let gate = TrustGateExecutor::new(MockExecutor, policy);
946        gate.set_effective_trust(SkillTrustLevel::Quarantined);
947
948        let result = gate
949            .execute_tool_call(&make_call("filesystem_read_file"))
950            .await;
951        assert!(result.is_ok());
952    }
953
954    #[tokio::test]
955    async fn quarantined_denies_mcp_bash_tool() {
956        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
957        gate.set_effective_trust(SkillTrustLevel::Quarantined);
958
959        let result = gate.execute_tool_call(&make_call("shell_bash")).await;
960        assert_matches!(result, Err(ToolError::Blocked { .. }));
961    }
962
963    #[tokio::test]
964    async fn quarantined_denies_mcp_memory_save() {
965        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
966        gate.set_effective_trust(SkillTrustLevel::Quarantined);
967
968        let result = gate
969            .execute_tool_call(&make_call("server_memory_save"))
970            .await;
971        assert_matches!(result, Err(ToolError::Blocked { .. }));
972    }
973
974    #[tokio::test]
975    async fn quarantined_denies_mcp_confirmed_path() {
976        // execute_tool_call_confirmed also enforces quarantine via is_quarantine_denied
977        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
978        gate.set_effective_trust(SkillTrustLevel::Quarantined);
979
980        let result = gate
981            .execute_tool_call_confirmed(&make_call("filesystem_write"))
982            .await;
983        assert_matches!(result, Err(ToolError::Blocked { .. }));
984    }
985
986    // mcp_tool_ids registry tests
987
988    fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
989        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
990        let handle = gate.mcp_tool_ids_handle();
991        let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
992        *handle.write() = set;
993        gate
994    }
995
996    #[tokio::test]
997    async fn quarantined_denies_registered_mcp_tool_novel_name() {
998        // "github_run_command" has no QUARANTINE_DENIED suffix match, but is registered as MCP.
999        let gate = gate_with_mcp_ids(&["github_run_command"]);
1000        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1001
1002        let result = gate
1003            .execute_tool_call(&make_call("github_run_command"))
1004            .await;
1005        assert_matches!(result, Err(ToolError::Blocked { .. }));
1006    }
1007
1008    #[tokio::test]
1009    async fn quarantined_denies_registered_mcp_tool_execute() {
1010        // "shell_execute" — no suffix match on "execute", but registered as MCP.
1011        let gate = gate_with_mcp_ids(&["shell_execute"]);
1012        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1013
1014        let result = gate.execute_tool_call(&make_call("shell_execute")).await;
1015        assert_matches!(result, Err(ToolError::Blocked { .. }));
1016    }
1017
1018    #[tokio::test]
1019    async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
1020        // Tool not in MCP set and not in QUARANTINE_DENIED — allowed.
1021        let gate = gate_with_mcp_ids(&["other_tool"]);
1022        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1023
1024        let result = gate.execute_tool_call(&make_call("read")).await;
1025        assert!(result.is_ok());
1026    }
1027
1028    #[tokio::test]
1029    async fn trusted_allows_registered_mcp_tool() {
1030        // At Trusted level, MCP registry check must NOT fire.
1031        let gate = gate_with_mcp_ids(&["github_run_command"]);
1032        gate.set_effective_trust(SkillTrustLevel::Trusted);
1033
1034        let result = gate
1035            .execute_tool_call(&make_call("github_run_command"))
1036            .await;
1037        assert!(result.is_ok());
1038    }
1039
1040    #[tokio::test]
1041    async fn quarantined_denies_mcp_tool_via_confirmed_path() {
1042        // execute_tool_call_confirmed must also check the MCP registry.
1043        let gate = gate_with_mcp_ids(&["docker_container_exec"]);
1044        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1045
1046        let result = gate
1047            .execute_tool_call_confirmed(&make_call("docker_container_exec"))
1048            .await;
1049        assert_matches!(result, Err(ToolError::Blocked { .. }));
1050    }
1051
1052    #[test]
1053    fn mcp_tool_ids_handle_shared_arc() {
1054        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1055        let handle = gate.mcp_tool_ids_handle();
1056        handle.write().insert("test_tool".to_owned());
1057        assert!(gate.is_mcp_tool("test_tool"));
1058        assert!(!gate.is_mcp_tool("other_tool"));
1059    }
1060
1061    // M9: document that the suffix matcher applies to MCP tools ending with
1062    // `_invoke_skill` or `_load_skill`. Future MCP tool authors should be aware.
1063    #[test]
1064    fn invoke_skill_and_load_skill_suffix_match_is_intentional() {
1065        // Exact-match branch: native tool IDs are denied.
1066        assert!(is_quarantine_denied("invoke_skill"));
1067        assert!(is_quarantine_denied("load_skill"));
1068        // Suffix-match branch: hypothetical MCP-prefixed versions are also denied.
1069        // This is intentional — prevents a renamed MCP wrapper from bypassing the gate.
1070        assert!(is_quarantine_denied("foo_invoke_skill"));
1071        assert!(is_quarantine_denied("foo_load_skill"));
1072    }
1073}