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