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