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 checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
295        self.inner.checkpoint_undo(n)
296    }
297
298    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
299        self.inner.checkpoint_redo()
300    }
301
302    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
303        self.inner.checkpoint_list()
304    }
305
306    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
307        self.effective_trust
308            .store(trust_to_u8(level), Ordering::Relaxed);
309    }
310
311    /// Returns `true` when the current policy would require confirmation for `call`.
312    ///
313    /// Mirrors the decision in [`execute_tool_call`](Self::execute_tool_call) without
314    /// executing the tool. The speculative engine calls this to skip dispatch for tools
315    /// that require user approval.
316    fn requires_confirmation(&self, call: &crate::executor::ToolCall) -> bool {
317        let input = call
318            .params
319            .get("command")
320            .or_else(|| call.params.get("file_path"))
321            .or_else(|| call.params.get("query"))
322            .or_else(|| call.params.get("url"))
323            .or_else(|| call.params.get("uri"))
324            .and_then(|v| v.as_str())
325            .unwrap_or("");
326        matches!(
327            self.check_trust(
328                call.tool_id.as_str(),
329                input,
330                call.skill_name.as_deref().unwrap_or(&[]),
331            ),
332            Err(ToolError::ConfirmationRequired { .. })
333        )
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::assert_matches;
341
342    #[derive(Debug)]
343    struct MockExecutor;
344    impl ToolExecutor for MockExecutor {
345        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
346            Ok(None)
347        }
348        async fn execute_tool_call(
349            &self,
350            call: &ToolCall,
351        ) -> Result<Option<ToolOutput>, ToolError> {
352            Ok(Some(ToolOutput {
353                tool_name: call.tool_id.clone(),
354                summary: "ok".into(),
355                blocks_executed: 1,
356                filter_stats: None,
357                diff: None,
358                streamed: false,
359                terminal_id: None,
360                locations: None,
361                raw_response: None,
362                claim_source: None,
363                ..Default::default()
364            }))
365        }
366
367        crate::tool_executor_no_inner_defaults!();
368    }
369
370    fn make_call(tool_id: &str) -> ToolCall {
371        ToolCall {
372            tool_id: tool_id.into(),
373            params: serde_json::Map::new(),
374            caller_id: None,
375            context: None,
376
377            tool_call_id: String::new(),
378            skill_name: None,
379        }
380    }
381
382    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
383        let mut params = serde_json::Map::new();
384        params.insert("command".into(), serde_json::Value::String(cmd.into()));
385        ToolCall {
386            tool_id: tool_id.into(),
387            params,
388            caller_id: None,
389            context: None,
390
391            tool_call_id: String::new(),
392            skill_name: None,
393        }
394    }
395
396    fn make_call_with_skills(tool_id: &str, skills: &[&str]) -> ToolCall {
397        ToolCall {
398            tool_id: tool_id.into(),
399            params: serde_json::Map::new(),
400            caller_id: None,
401            context: None,
402
403            tool_call_id: String::new(),
404            skill_name: Some(skills.iter().map(ToString::to_string).collect()),
405        }
406    }
407
408    fn blocked_command(result: Result<Option<ToolOutput>, ToolError>) -> String {
409        match result {
410            Err(ToolError::Blocked { command }) => command,
411            other => panic!("expected Err(ToolError::Blocked {{ .. }}), got {other:?}"),
412        }
413    }
414
415    #[tokio::test]
416    async fn supervised_readonly_native_tool_without_rule_allowed() {
417        // "read" is a native read-only tool (permissions::READONLY_TOOLS) — it must
418        // still bypass the Ask default in Supervised mode even without an explicit rule.
419        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
420        gate.set_effective_trust(SkillTrustLevel::Trusted);
421
422        let result = gate.execute_tool_call(&make_call("read")).await;
423        assert!(result.is_ok());
424    }
425
426    /// Regression test for #5575: `bash` has no explicit policy rule and is neither an
427    /// MCP tool nor a native read-only tool, so it must NOT bypass confirmation in
428    /// Supervised mode — the prior blanket skip incorrectly allowed this.
429    #[tokio::test]
430    async fn supervised_unconfigured_non_mcp_non_readonly_tool_requires_confirmation() {
431        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
432        gate.set_effective_trust(SkillTrustLevel::Trusted);
433
434        let result = gate.execute_tool_call(&make_call("bash")).await;
435        assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
436    }
437
438    /// Regression test for #5575: `diagnostics` runs `cargo check`/`cargo clippy`, which
439    /// executes arbitrary code via `build.rs` scripts and proc-macros — it must require
440    /// confirmation in Supervised mode when no explicit rule is configured, not bypass it
441    /// via the (now-removed) blanket "no rule => Ok" skip.
442    #[tokio::test]
443    async fn supervised_unconfigured_diagnostics_requires_confirmation() {
444        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
445        gate.set_effective_trust(SkillTrustLevel::Trusted);
446
447        let result = gate.execute_tool_call(&make_call("diagnostics")).await;
448        assert_matches!(result, Err(ToolError::ConfirmationRequired { .. }));
449    }
450
451    #[tokio::test]
452    async fn quarantined_denies_bash() {
453        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
454        gate.set_effective_trust(SkillTrustLevel::Quarantined);
455
456        let result = gate.execute_tool_call(&make_call("bash")).await;
457        assert_matches!(result, Err(ToolError::Blocked { .. }));
458    }
459
460    #[tokio::test]
461    async fn quarantined_denies_write() {
462        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
463        gate.set_effective_trust(SkillTrustLevel::Quarantined);
464
465        let result = gate.execute_tool_call(&make_call("write")).await;
466        assert_matches!(result, Err(ToolError::Blocked { .. }));
467    }
468
469    #[tokio::test]
470    async fn quarantined_denies_edit() {
471        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
472        gate.set_effective_trust(SkillTrustLevel::Quarantined);
473
474        let result = gate.execute_tool_call(&make_call("edit")).await;
475        assert_matches!(result, Err(ToolError::Blocked { .. }));
476    }
477
478    #[tokio::test]
479    async fn quarantined_denies_delete_path() {
480        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
481        gate.set_effective_trust(SkillTrustLevel::Quarantined);
482
483        let result = gate.execute_tool_call(&make_call("delete_path")).await;
484        assert_matches!(result, Err(ToolError::Blocked { .. }));
485    }
486
487    #[tokio::test]
488    async fn quarantined_denies_fetch() {
489        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
490        gate.set_effective_trust(SkillTrustLevel::Quarantined);
491
492        let result = gate.execute_tool_call(&make_call("fetch")).await;
493        assert_matches!(result, Err(ToolError::Blocked { .. }));
494    }
495
496    #[tokio::test]
497    async fn quarantined_denies_memory_save() {
498        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
499        gate.set_effective_trust(SkillTrustLevel::Quarantined);
500
501        let result = gate.execute_tool_call(&make_call("memory_save")).await;
502        assert_matches!(result, Err(ToolError::Blocked { .. }));
503    }
504
505    /// Regression test for #5433: `diagnostics` runs `cargo check`/`cargo clippy`, which
506    /// executes arbitrary code via `build.rs` scripts and proc-macros in the target
507    /// workspace — equivalent to `bash` for security purposes. Now that #5433 wires
508    /// `DiagnosticsExecutor` into the live, `TrustGateExecutor`-gated composite chain, it
509    /// must be quarantine-denied like `bash`.
510    #[tokio::test]
511    async fn quarantined_denies_diagnostics() {
512        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
513        gate.set_effective_trust(SkillTrustLevel::Quarantined);
514
515        let result = gate.execute_tool_call(&make_call("diagnostics")).await;
516        assert_matches!(result, Err(ToolError::Blocked { .. }));
517    }
518
519    #[tokio::test]
520    async fn quarantined_allows_read() {
521        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
522        let gate = TrustGateExecutor::new(MockExecutor, policy);
523        gate.set_effective_trust(SkillTrustLevel::Quarantined);
524
525        // "read" (file read) is not in QUARANTINE_DENIED — should be allowed
526        let result = gate.execute_tool_call(&make_call("read")).await;
527        assert!(result.is_ok());
528    }
529
530    #[tokio::test]
531    async fn quarantined_allows_file_read() {
532        // "file_read" is not in the quarantine-denied list, but (unlike "read") it is also
533        // not in `permissions::READONLY_TOOLS`, so an explicit Allow rule is required here
534        // to isolate this test from the Supervised-mode Ask default (see #5575) and keep it
535        // focused on quarantine-denial behavior only.
536        let mut rules = std::collections::HashMap::new();
537        rules.insert(
538            "file_read".to_owned(),
539            vec![crate::permissions::PermissionRule {
540                pattern: "*".to_owned(),
541                action: PermissionAction::Allow,
542            }],
543        );
544        let policy = crate::permissions::PermissionPolicy::new(rules);
545        let gate = TrustGateExecutor::new(MockExecutor, policy);
546        gate.set_effective_trust(SkillTrustLevel::Quarantined);
547
548        let result = gate.execute_tool_call(&make_call("file_read")).await;
549        // file_read is not in quarantine denied list, and the explicit rule allows it => Ok
550        assert!(result.is_ok());
551    }
552
553    #[tokio::test]
554    async fn blocked_denies_everything() {
555        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
556        gate.set_effective_trust(SkillTrustLevel::Blocked);
557
558        let result = gate.execute_tool_call(&make_call("file_read")).await;
559        assert_matches!(result, Err(ToolError::Blocked { .. }));
560    }
561
562    #[tokio::test]
563    async fn policy_deny_overrides_trust() {
564        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
565        let gate = TrustGateExecutor::new(MockExecutor, policy);
566        gate.set_effective_trust(SkillTrustLevel::Trusted);
567
568        let result = gate
569            .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
570            .await;
571        assert_matches!(result, Err(ToolError::Blocked { .. }));
572    }
573
574    #[tokio::test]
575    async fn blocked_denies_execute() {
576        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
577        gate.set_effective_trust(SkillTrustLevel::Blocked);
578
579        let result = gate.execute("some response").await;
580        assert_matches!(result, Err(ToolError::Blocked { .. }));
581    }
582
583    #[tokio::test]
584    async fn blocked_denies_execute_confirmed() {
585        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
586        gate.set_effective_trust(SkillTrustLevel::Blocked);
587
588        let result = gate.execute_confirmed("some response").await;
589        assert_matches!(result, Err(ToolError::Blocked { .. }));
590    }
591
592    #[tokio::test]
593    async fn trusted_allows_execute() {
594        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
595        gate.set_effective_trust(SkillTrustLevel::Trusted);
596
597        let result = gate.execute("some response").await;
598        assert!(result.is_ok());
599    }
600
601    #[tokio::test]
602    async fn verified_with_allow_policy_succeeds() {
603        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
604        let gate = TrustGateExecutor::new(MockExecutor, policy);
605        gate.set_effective_trust(SkillTrustLevel::Verified);
606
607        let result = gate
608            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
609            .await
610            .unwrap();
611        assert!(result.is_some());
612    }
613
614    #[tokio::test]
615    async fn quarantined_denies_web_scrape() {
616        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
617        gate.set_effective_trust(SkillTrustLevel::Quarantined);
618
619        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
620        assert_matches!(result, Err(ToolError::Blocked { .. }));
621    }
622
623    /// Regression test for #5729: the denial message must name the turn's actual co-active
624    /// skill set instead of implying `invoke_skill` itself is the untrusted party. The gate
625    /// behavior (deny) is unchanged — only the message wording is under test here.
626    #[tokio::test]
627    async fn quarantined_denial_message_names_active_skills_not_target_tool() {
628        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
629        gate.set_effective_trust(SkillTrustLevel::Quarantined);
630
631        let call =
632            make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
633        let result = gate.execute_tool_call(&call).await;
634        let message = blocked_command(result);
635
636        assert!(
637            message.contains("disk-usage") && message.contains("persona-customer-support"),
638            "message should name the actual active skills, got: {message}"
639        );
640        assert_ne!(
641            message, "invoke_skill denied (trust=quarantined)",
642            "message must not read as if invoke_skill itself is the untrusted party"
643        );
644    }
645
646    /// Regression test for #5729: when no active skills are recorded on the call
647    /// (`skill_name: None`), the denial message must remain exactly the pre-fix format —
648    /// this guards backward compatibility for all pre-existing tests in this module, which
649    /// all use `make_call`/`make_call_with_cmd` (both hardcode `skill_name: None`).
650    #[tokio::test]
651    async fn quarantined_denial_message_unchanged_when_no_active_skills() {
652        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
653        gate.set_effective_trust(SkillTrustLevel::Quarantined);
654
655        let result = gate.execute_tool_call(&make_call("invoke_skill")).await;
656        let message = blocked_command(result);
657
658        assert_eq!(message, "invoke_skill denied (trust=quarantined)");
659    }
660
661    /// Same as `quarantined_denial_message_unchanged_when_no_active_skills`, but with an
662    /// explicit empty skill list rather than `None` — both must produce the old message.
663    #[tokio::test]
664    async fn quarantined_denial_message_unchanged_when_active_skills_empty() {
665        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
666        gate.set_effective_trust(SkillTrustLevel::Quarantined);
667
668        let result = gate
669            .execute_tool_call(&make_call_with_skills("invoke_skill", &[]))
670            .await;
671        let message = blocked_command(result);
672
673        assert_eq!(message, "invoke_skill denied (trust=quarantined)");
674    }
675
676    /// Regression test for #5729 via the `execute_tool_call_confirmed` path, which has its
677    /// own duplicate inline Quarantined check rather than routing through `check_trust`.
678    #[tokio::test]
679    async fn quarantined_denial_message_names_active_skills_confirmed_path() {
680        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
681        gate.set_effective_trust(SkillTrustLevel::Quarantined);
682
683        let call =
684            make_call_with_skills("invoke_skill", &["disk-usage", "persona-customer-support"]);
685        let result = gate.execute_tool_call_confirmed(&call).await;
686        let message = blocked_command(result);
687
688        assert!(
689            message.contains("disk-usage") && message.contains("persona-customer-support"),
690            "confirmed path message should name the actual active skills, got: {message}"
691        );
692        assert_ne!(
693            message, "invoke_skill denied (trust=quarantined)",
694            "confirmed path message must not read as if invoke_skill itself is untrusted"
695        );
696    }
697
698    /// Regression test for #5729: the message fix must apply uniformly to any
699    /// `QUARANTINE_DENIED` tool, not just `invoke_skill` — verified here with `bash`.
700    #[tokio::test]
701    async fn quarantined_denial_message_names_active_skills_for_non_skill_tool() {
702        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
703        gate.set_effective_trust(SkillTrustLevel::Quarantined);
704
705        let call = make_call_with_skills("bash", &["disk-usage", "persona-customer-support"]);
706        let result = gate.execute_tool_call(&call).await;
707        let message = blocked_command(result);
708
709        assert!(
710            message.contains("disk-usage") && message.contains("persona-customer-support"),
711            "message for a non-skill tool should also name the active skills, got: {message}"
712        );
713        assert_ne!(message, "bash denied (trust=quarantined)");
714    }
715
716    #[derive(Debug)]
717    struct EnvCapture {
718        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
719    }
720    impl EnvCapture {
721        fn new() -> Self {
722            Self {
723                captured: std::sync::Mutex::new(None),
724            }
725        }
726    }
727    impl ToolExecutor for EnvCapture {
728        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
729            Ok(None)
730        }
731        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
732            Ok(None)
733        }
734        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
735            *self.captured.lock().unwrap() = env;
736        }
737
738        crate::tool_executor_no_inner_defaults!();
739    }
740
741    #[test]
742    fn is_tool_retryable_delegated_to_inner() {
743        #[derive(Debug)]
744        struct RetryableExecutor;
745        impl ToolExecutor for RetryableExecutor {
746            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
747                Ok(None)
748            }
749            async fn execute_tool_call(
750                &self,
751                _: &ToolCall,
752            ) -> Result<Option<ToolOutput>, ToolError> {
753                Ok(None)
754            }
755            fn is_tool_retryable(&self, tool_id: &str) -> bool {
756                tool_id == "fetch"
757            }
758
759            crate::tool_executor_no_inner_defaults!();
760        }
761        let gate = TrustGateExecutor::new(RetryableExecutor, PermissionPolicy::default());
762        assert!(gate.is_tool_retryable("fetch"));
763        assert!(!gate.is_tool_retryable("bash"));
764    }
765
766    #[test]
767    fn checkpoint_methods_delegated_to_inner() {
768        #[derive(Debug)]
769        struct CheckpointingExecutor;
770        impl ToolExecutor for CheckpointingExecutor {
771            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
772                Ok(None)
773            }
774            async fn execute_tool_call(
775                &self,
776                _: &ToolCall,
777            ) -> Result<Option<ToolOutput>, ToolError> {
778                Ok(None)
779            }
780            fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
781                crate::executor::CheckpointActionResult {
782                    supported: true,
783                    message: "stub".into(),
784                    reverted_commands: n,
785                    ..Default::default()
786                }
787            }
788            fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
789                crate::executor::CheckpointActionResult {
790                    supported: true,
791                    message: "stub".into(),
792                    ..Default::default()
793                }
794            }
795            fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
796                crate::executor::CheckpointListResult {
797                    supported: true,
798                    ..Default::default()
799                }
800            }
801            async fn execute_tool_call_confirmed(
802                &self,
803                call: &ToolCall,
804            ) -> Result<Option<ToolOutput>, ToolError> {
805                self.execute_tool_call(call).await
806            }
807            fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
808                false
809            }
810            fn requires_confirmation(&self, _call: &ToolCall) -> bool {
811                false
812            }
813        }
814        let gate = TrustGateExecutor::new(CheckpointingExecutor, PermissionPolicy::default());
815        let undo_result = gate.checkpoint_undo(7);
816        assert!(undo_result.supported);
817        assert_eq!(
818            undo_result.reverted_commands, 7,
819            "n must be forwarded, not hardcoded"
820        );
821        assert!(gate.checkpoint_redo().supported);
822        assert!(gate.checkpoint_list().supported);
823    }
824
825    #[test]
826    fn set_skill_env_forwarded_to_inner() {
827        let inner = EnvCapture::new();
828        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
829
830        let mut env = std::collections::HashMap::new();
831        env.insert("MY_VAR".to_owned(), "42".to_owned());
832        gate.set_skill_env(Some(env.clone()));
833
834        let captured = gate.inner.captured.lock().unwrap();
835        assert_eq!(*captured, Some(env));
836    }
837
838    #[tokio::test]
839    async fn mcp_tool_supervised_no_rules_allows() {
840        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok.
841        // Registered via `mcp_tool_ids_handle` so `is_mcp_tool` recognizes it as genuinely
842        // MCP-sourced — see #5575 (the skip is no longer a blanket "no rule => Ok").
843        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
844        let gate = TrustGateExecutor::new(MockExecutor, policy);
845        gate.set_effective_trust(SkillTrustLevel::Trusted);
846        gate.mcp_tool_ids_handle()
847            .write()
848            .insert("mcp_filesystem__read_file".to_owned());
849
850        let mut params = serde_json::Map::new();
851        params.insert(
852            "file_path".into(),
853            serde_json::Value::String("/tmp/test.txt".into()),
854        );
855        let call = ToolCall {
856            tool_id: "mcp_filesystem__read_file".into(),
857            params,
858            caller_id: None,
859            context: None,
860
861            tool_call_id: String::new(),
862            skill_name: None,
863        };
864        let result = gate.execute_tool_call(&call).await;
865        assert!(
866            result.is_ok(),
867            "MCP tool should be allowed when no rules exist"
868        );
869    }
870
871    #[tokio::test]
872    async fn bash_with_explicit_deny_rule_blocked() {
873        // Bash with explicit Deny rule => Err(ToolCallBlocked)
874        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
875        let gate = TrustGateExecutor::new(MockExecutor, policy);
876        gate.set_effective_trust(SkillTrustLevel::Trusted);
877
878        let result = gate
879            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
880            .await;
881        assert!(
882            matches!(result, Err(ToolError::Blocked { .. })),
883            "bash with explicit deny rule should be blocked"
884        );
885    }
886
887    #[tokio::test]
888    async fn bash_with_explicit_allow_rule_succeeds() {
889        // Tool with explicit Allow rules => Ok
890        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
891        let gate = TrustGateExecutor::new(MockExecutor, policy);
892        gate.set_effective_trust(SkillTrustLevel::Trusted);
893
894        let result = gate
895            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
896            .await;
897        assert!(
898            result.is_ok(),
899            "bash with explicit allow rule should succeed"
900        );
901    }
902
903    #[tokio::test]
904    async fn readonly_denies_mcp_tool_not_in_allowlist() {
905        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
906        let policy =
907            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
908        let gate = TrustGateExecutor::new(MockExecutor, policy);
909        gate.set_effective_trust(SkillTrustLevel::Trusted);
910
911        let result = gate
912            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
913            .await;
914        assert!(
915            matches!(result, Err(ToolError::Blocked { .. })),
916            "ReadOnly mode must deny non-allowlisted tools"
917        );
918    }
919
920    #[test]
921    fn set_effective_trust_interior_mutability() {
922        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
923        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
924
925        gate.set_effective_trust(SkillTrustLevel::Quarantined);
926        assert_eq!(gate.effective_trust(), SkillTrustLevel::Quarantined);
927
928        gate.set_effective_trust(SkillTrustLevel::Blocked);
929        assert_eq!(gate.effective_trust(), SkillTrustLevel::Blocked);
930
931        gate.set_effective_trust(SkillTrustLevel::Trusted);
932        assert_eq!(gate.effective_trust(), SkillTrustLevel::Trusted);
933    }
934
935    // is_quarantine_denied unit tests
936
937    #[test]
938    fn is_quarantine_denied_exact_match() {
939        assert!(is_quarantine_denied("bash"));
940        assert!(is_quarantine_denied("write"));
941        assert!(is_quarantine_denied("fetch"));
942        assert!(is_quarantine_denied("memory_save"));
943        assert!(is_quarantine_denied("delete_path"));
944        assert!(is_quarantine_denied("create_directory"));
945        assert!(is_quarantine_denied("diagnostics"));
946    }
947
948    #[test]
949    fn is_quarantine_denied_suffix_match_mcp_write() {
950        // "filesystem_write" ends with "_write" -> denied
951        assert!(is_quarantine_denied("filesystem_write"));
952        // "filesystem_write_file" ends with "_file", not "_write" -> NOT denied
953        assert!(!is_quarantine_denied("filesystem_write_file"));
954    }
955
956    #[test]
957    fn is_quarantine_denied_suffix_mcp_bash() {
958        assert!(is_quarantine_denied("shell_bash"));
959        assert!(is_quarantine_denied("mcp_shell_bash"));
960    }
961
962    #[test]
963    fn is_quarantine_denied_suffix_mcp_fetch() {
964        assert!(is_quarantine_denied("http_fetch"));
965        // "server_prefetch" ends with "_prefetch", not "_fetch"
966        assert!(!is_quarantine_denied("server_prefetch"));
967    }
968
969    #[test]
970    fn is_quarantine_denied_suffix_mcp_memory_save() {
971        assert!(is_quarantine_denied("server_memory_save"));
972        // "_save" alone does NOT match the multi-word entry "memory_save"
973        assert!(!is_quarantine_denied("server_save"));
974    }
975
976    #[test]
977    fn is_quarantine_denied_suffix_mcp_delete_path() {
978        assert!(is_quarantine_denied("fs_delete_path"));
979        // "fs_not_delete_path" ends with "_delete_path" as well — suffix check is correct
980        assert!(is_quarantine_denied("fs_not_delete_path"));
981    }
982
983    #[test]
984    fn is_quarantine_denied_substring_not_suffix() {
985        // "write_log" ends with "_log", NOT "_write" — must NOT be denied
986        assert!(!is_quarantine_denied("write_log"));
987    }
988
989    #[test]
990    fn is_quarantine_denied_read_only_tools_allowed() {
991        assert!(!is_quarantine_denied("filesystem_read_file"));
992        assert!(!is_quarantine_denied("filesystem_list_dir"));
993        assert!(!is_quarantine_denied("read"));
994        assert!(!is_quarantine_denied("file_read"));
995    }
996
997    #[tokio::test]
998    async fn quarantined_denies_mcp_write_tool() {
999        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1000        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1001
1002        let result = gate.execute_tool_call(&make_call("filesystem_write")).await;
1003        assert_matches!(result, Err(ToolError::Blocked { .. }));
1004    }
1005
1006    #[tokio::test]
1007    async fn quarantined_allows_mcp_read_file() {
1008        // Deliberately NOT registered in `mcp_tool_ids`: a tool registered there is
1009        // denied outright under Quarantine regardless of read/write (see
1010        // `mcp_tool_ids` field docs), so this test isolates a different case — a
1011        // tool that merely looks MCP-like by name but isn't quarantine-denied by
1012        // `is_quarantine_denied`. An explicit Allow rule keeps it decoupled from the
1013        // Supervised-mode Ask default for unconfigured tools (see #5575).
1014        let mut rules = std::collections::HashMap::new();
1015        rules.insert(
1016            "filesystem_read_file".to_owned(),
1017            vec![crate::permissions::PermissionRule {
1018                pattern: "*".to_owned(),
1019                action: PermissionAction::Allow,
1020            }],
1021        );
1022        let policy = crate::permissions::PermissionPolicy::new(rules);
1023        let gate = TrustGateExecutor::new(MockExecutor, policy);
1024        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1025
1026        let result = gate
1027            .execute_tool_call(&make_call("filesystem_read_file"))
1028            .await;
1029        assert!(result.is_ok());
1030    }
1031
1032    #[tokio::test]
1033    async fn quarantined_denies_mcp_bash_tool() {
1034        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1035        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1036
1037        let result = gate.execute_tool_call(&make_call("shell_bash")).await;
1038        assert_matches!(result, Err(ToolError::Blocked { .. }));
1039    }
1040
1041    #[tokio::test]
1042    async fn quarantined_denies_mcp_memory_save() {
1043        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1044        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1045
1046        let result = gate
1047            .execute_tool_call(&make_call("server_memory_save"))
1048            .await;
1049        assert_matches!(result, Err(ToolError::Blocked { .. }));
1050    }
1051
1052    #[tokio::test]
1053    async fn quarantined_denies_mcp_confirmed_path() {
1054        // execute_tool_call_confirmed also enforces quarantine via is_quarantine_denied
1055        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1056        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1057
1058        let result = gate
1059            .execute_tool_call_confirmed(&make_call("filesystem_write"))
1060            .await;
1061        assert_matches!(result, Err(ToolError::Blocked { .. }));
1062    }
1063
1064    // mcp_tool_ids registry tests
1065
1066    fn gate_with_mcp_ids(ids: &[&str]) -> TrustGateExecutor<MockExecutor> {
1067        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1068        let handle = gate.mcp_tool_ids_handle();
1069        let set: std::collections::HashSet<String> = ids.iter().map(ToString::to_string).collect();
1070        *handle.write() = set;
1071        gate
1072    }
1073
1074    #[tokio::test]
1075    async fn quarantined_denies_registered_mcp_tool_novel_name() {
1076        // "github_run_command" has no QUARANTINE_DENIED suffix match, but is registered as MCP.
1077        let gate = gate_with_mcp_ids(&["github_run_command"]);
1078        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1079
1080        let result = gate
1081            .execute_tool_call(&make_call("github_run_command"))
1082            .await;
1083        assert_matches!(result, Err(ToolError::Blocked { .. }));
1084    }
1085
1086    #[tokio::test]
1087    async fn quarantined_denies_registered_mcp_tool_execute() {
1088        // "shell_execute" — no suffix match on "execute", but registered as MCP.
1089        let gate = gate_with_mcp_ids(&["shell_execute"]);
1090        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1091
1092        let result = gate.execute_tool_call(&make_call("shell_execute")).await;
1093        assert_matches!(result, Err(ToolError::Blocked { .. }));
1094    }
1095
1096    #[tokio::test]
1097    async fn quarantined_allows_unregistered_tool_not_in_denied_list() {
1098        // Tool not in MCP set and not in QUARANTINE_DENIED — allowed.
1099        let gate = gate_with_mcp_ids(&["other_tool"]);
1100        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1101
1102        let result = gate.execute_tool_call(&make_call("read")).await;
1103        assert!(result.is_ok());
1104    }
1105
1106    #[tokio::test]
1107    async fn trusted_allows_registered_mcp_tool() {
1108        // At Trusted level, MCP registry check must NOT fire.
1109        let gate = gate_with_mcp_ids(&["github_run_command"]);
1110        gate.set_effective_trust(SkillTrustLevel::Trusted);
1111
1112        let result = gate
1113            .execute_tool_call(&make_call("github_run_command"))
1114            .await;
1115        assert!(result.is_ok());
1116    }
1117
1118    #[tokio::test]
1119    async fn quarantined_denies_mcp_tool_via_confirmed_path() {
1120        // execute_tool_call_confirmed must also check the MCP registry.
1121        let gate = gate_with_mcp_ids(&["docker_container_exec"]);
1122        gate.set_effective_trust(SkillTrustLevel::Quarantined);
1123
1124        let result = gate
1125            .execute_tool_call_confirmed(&make_call("docker_container_exec"))
1126            .await;
1127        assert_matches!(result, Err(ToolError::Blocked { .. }));
1128    }
1129
1130    #[test]
1131    fn mcp_tool_ids_handle_shared_arc() {
1132        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
1133        let handle = gate.mcp_tool_ids_handle();
1134        handle.write().insert("test_tool".to_owned());
1135        assert!(gate.is_mcp_tool("test_tool"));
1136        assert!(!gate.is_mcp_tool("other_tool"));
1137    }
1138
1139    // M9: document that the suffix matcher applies to MCP tools ending with
1140    // `_invoke_skill` or `_load_skill`. Future MCP tool authors should be aware.
1141    #[test]
1142    fn invoke_skill_and_load_skill_suffix_match_is_intentional() {
1143        // Exact-match branch: native tool IDs are denied.
1144        assert!(is_quarantine_denied("invoke_skill"));
1145        assert!(is_quarantine_denied("load_skill"));
1146        // Suffix-match branch: hypothetical MCP-prefixed versions are also denied.
1147        // This is intentional — prevents a renamed MCP wrapper from bypassing the gate.
1148        assert!(is_quarantine_denied("foo_invoke_skill"));
1149        assert!(is_quarantine_denied("foo_load_skill"));
1150    }
1151}