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::sync::atomic::{AtomicU8, Ordering};
7
8use crate::TrustLevel;
9
10use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
11use crate::permissions::{AutonomyLevel, PermissionAction, PermissionPolicy};
12use crate::registry::ToolDef;
13
14/// Tools denied when a Quarantined skill is active.
15const QUARANTINE_DENIED: &[&str] = &["bash", "file_write", "web_scrape"];
16
17fn trust_to_u8(level: TrustLevel) -> u8 {
18    match level {
19        TrustLevel::Trusted => 0,
20        TrustLevel::Verified => 1,
21        TrustLevel::Quarantined => 2,
22        TrustLevel::Blocked => 3,
23    }
24}
25
26fn u8_to_trust(v: u8) -> TrustLevel {
27    match v {
28        0 => TrustLevel::Trusted,
29        1 => TrustLevel::Verified,
30        2 => TrustLevel::Quarantined,
31        _ => TrustLevel::Blocked,
32    }
33}
34
35/// Wraps an inner `ToolExecutor` and applies trust-level permission overlays.
36pub struct TrustGateExecutor<T: ToolExecutor> {
37    inner: T,
38    policy: PermissionPolicy,
39    effective_trust: AtomicU8,
40}
41
42impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for TrustGateExecutor<T> {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("TrustGateExecutor")
45            .field("inner", &self.inner)
46            .field("policy", &self.policy)
47            .field("effective_trust", &self.effective_trust())
48            .finish()
49    }
50}
51
52impl<T: ToolExecutor> TrustGateExecutor<T> {
53    #[must_use]
54    pub fn new(inner: T, policy: PermissionPolicy) -> Self {
55        Self {
56            inner,
57            policy,
58            effective_trust: AtomicU8::new(trust_to_u8(TrustLevel::Trusted)),
59        }
60    }
61
62    pub fn set_effective_trust(&self, level: TrustLevel) {
63        self.effective_trust
64            .store(trust_to_u8(level), Ordering::Relaxed);
65    }
66
67    #[must_use]
68    pub fn effective_trust(&self) -> TrustLevel {
69        u8_to_trust(self.effective_trust.load(Ordering::Relaxed))
70    }
71
72    fn check_trust(&self, tool_id: &str, input: &str) -> Result<(), ToolError> {
73        match self.effective_trust() {
74            TrustLevel::Blocked => {
75                return Err(ToolError::Blocked {
76                    command: "all tools blocked (trust=blocked)".to_owned(),
77                });
78            }
79            TrustLevel::Quarantined => {
80                if QUARANTINE_DENIED.contains(&tool_id) {
81                    return Err(ToolError::Blocked {
82                        command: format!("{tool_id} denied (trust=quarantined)"),
83                    });
84                }
85            }
86            TrustLevel::Trusted | TrustLevel::Verified => {}
87        }
88
89        // PermissionPolicy was designed for the bash tool. In Supervised mode, tools
90        // without explicit rules default to Ask, which incorrectly blocks MCP/LSP tools.
91        // Skip the policy check for such tools — trust-level enforcement above is sufficient.
92        // ReadOnly mode is excluded: its allowlist is enforced inside policy.check().
93        if self.policy.autonomy_level() == AutonomyLevel::Supervised
94            && self.policy.rules().get(tool_id).is_none()
95        {
96            return Ok(());
97        }
98
99        match self.policy.check(tool_id, input) {
100            PermissionAction::Allow => Ok(()),
101            PermissionAction::Ask => Err(ToolError::ConfirmationRequired {
102                command: input.to_owned(),
103            }),
104            PermissionAction::Deny => Err(ToolError::Blocked {
105                command: input.to_owned(),
106            }),
107        }
108    }
109}
110
111impl<T: ToolExecutor> ToolExecutor for TrustGateExecutor<T> {
112    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
113        match self.effective_trust() {
114            TrustLevel::Blocked | TrustLevel::Quarantined => {
115                return Err(ToolError::Blocked {
116                    command: format!(
117                        "tool execution denied (trust={})",
118                        format!("{:?}", self.effective_trust()).to_lowercase()
119                    ),
120                });
121            }
122            TrustLevel::Trusted | TrustLevel::Verified => {}
123        }
124        self.inner.execute(response).await
125    }
126
127    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
128        match self.effective_trust() {
129            TrustLevel::Blocked | TrustLevel::Quarantined => {
130                return Err(ToolError::Blocked {
131                    command: format!(
132                        "tool execution denied (trust={})",
133                        format!("{:?}", self.effective_trust()).to_lowercase()
134                    ),
135                });
136            }
137            TrustLevel::Trusted | TrustLevel::Verified => {}
138        }
139        self.inner.execute_confirmed(response).await
140    }
141
142    fn tool_definitions(&self) -> Vec<ToolDef> {
143        self.inner.tool_definitions()
144    }
145
146    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
147        let input = call
148            .params
149            .get("command")
150            .or_else(|| call.params.get("file_path"))
151            .or_else(|| call.params.get("query"))
152            .or_else(|| call.params.get("url"))
153            .or_else(|| call.params.get("uri"))
154            .and_then(|v| v.as_str())
155            .unwrap_or("");
156        self.check_trust(&call.tool_id, input)?;
157        self.inner.execute_tool_call(call).await
158    }
159
160    async fn execute_tool_call_confirmed(
161        &self,
162        call: &ToolCall,
163    ) -> Result<Option<ToolOutput>, ToolError> {
164        // Bypass check_trust: caller already obtained user approval.
165        // Still enforce Blocked/Quarantined trust level constraints.
166        match self.effective_trust() {
167            TrustLevel::Blocked => {
168                return Err(ToolError::Blocked {
169                    command: "all tools blocked (trust=blocked)".to_owned(),
170                });
171            }
172            TrustLevel::Quarantined => {
173                if QUARANTINE_DENIED.contains(&call.tool_id.as_str()) {
174                    return Err(ToolError::Blocked {
175                        command: format!("{} denied (trust=quarantined)", call.tool_id),
176                    });
177                }
178            }
179            TrustLevel::Trusted | TrustLevel::Verified => {}
180        }
181        self.inner.execute_tool_call(call).await
182    }
183
184    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
185        self.inner.set_skill_env(env);
186    }
187
188    fn set_effective_trust(&self, level: crate::TrustLevel) {
189        self.effective_trust
190            .store(trust_to_u8(level), Ordering::Relaxed);
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[derive(Debug)]
199    struct MockExecutor;
200    impl ToolExecutor for MockExecutor {
201        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
202            Ok(None)
203        }
204        async fn execute_tool_call(
205            &self,
206            call: &ToolCall,
207        ) -> Result<Option<ToolOutput>, ToolError> {
208            Ok(Some(ToolOutput {
209                tool_name: call.tool_id.clone(),
210                summary: "ok".into(),
211                blocks_executed: 1,
212                filter_stats: None,
213                diff: None,
214                streamed: false,
215                terminal_id: None,
216                locations: None,
217                raw_response: None,
218            }))
219        }
220    }
221
222    fn make_call(tool_id: &str) -> ToolCall {
223        ToolCall {
224            tool_id: tool_id.into(),
225            params: serde_json::Map::new(),
226        }
227    }
228
229    fn make_call_with_cmd(tool_id: &str, cmd: &str) -> ToolCall {
230        let mut params = serde_json::Map::new();
231        params.insert("command".into(), serde_json::Value::String(cmd.into()));
232        ToolCall {
233            tool_id: tool_id.into(),
234            params,
235        }
236    }
237
238    #[tokio::test]
239    async fn trusted_allows_all() {
240        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
241        gate.set_effective_trust(TrustLevel::Trusted);
242
243        let result = gate.execute_tool_call(&make_call("bash")).await;
244        // Default policy has no rules for bash => skip policy check => Ok
245        assert!(result.is_ok());
246    }
247
248    #[tokio::test]
249    async fn quarantined_denies_bash() {
250        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
251        gate.set_effective_trust(TrustLevel::Quarantined);
252
253        let result = gate.execute_tool_call(&make_call("bash")).await;
254        assert!(matches!(result, Err(ToolError::Blocked { .. })));
255    }
256
257    #[tokio::test]
258    async fn quarantined_denies_file_write() {
259        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
260        gate.set_effective_trust(TrustLevel::Quarantined);
261
262        let result = gate.execute_tool_call(&make_call("file_write")).await;
263        assert!(matches!(result, Err(ToolError::Blocked { .. })));
264    }
265
266    #[tokio::test]
267    async fn quarantined_allows_file_read() {
268        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
269        let gate = TrustGateExecutor::new(MockExecutor, policy);
270        gate.set_effective_trust(TrustLevel::Quarantined);
271
272        let result = gate.execute_tool_call(&make_call("file_read")).await;
273        // file_read is not in quarantine denied list, and policy has no rules for file_read => Ok
274        assert!(result.is_ok());
275    }
276
277    #[tokio::test]
278    async fn blocked_denies_everything() {
279        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
280        gate.set_effective_trust(TrustLevel::Blocked);
281
282        let result = gate.execute_tool_call(&make_call("file_read")).await;
283        assert!(matches!(result, Err(ToolError::Blocked { .. })));
284    }
285
286    #[tokio::test]
287    async fn policy_deny_overrides_trust() {
288        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
289        let gate = TrustGateExecutor::new(MockExecutor, policy);
290        gate.set_effective_trust(TrustLevel::Trusted);
291
292        let result = gate
293            .execute_tool_call(&make_call_with_cmd("bash", "sudo rm"))
294            .await;
295        assert!(matches!(result, Err(ToolError::Blocked { .. })));
296    }
297
298    #[tokio::test]
299    async fn blocked_denies_execute() {
300        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
301        gate.set_effective_trust(TrustLevel::Blocked);
302
303        let result = gate.execute("some response").await;
304        assert!(matches!(result, Err(ToolError::Blocked { .. })));
305    }
306
307    #[tokio::test]
308    async fn blocked_denies_execute_confirmed() {
309        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
310        gate.set_effective_trust(TrustLevel::Blocked);
311
312        let result = gate.execute_confirmed("some response").await;
313        assert!(matches!(result, Err(ToolError::Blocked { .. })));
314    }
315
316    #[tokio::test]
317    async fn trusted_allows_execute() {
318        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
319        gate.set_effective_trust(TrustLevel::Trusted);
320
321        let result = gate.execute("some response").await;
322        assert!(result.is_ok());
323    }
324
325    #[tokio::test]
326    async fn verified_with_allow_policy_succeeds() {
327        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
328        let gate = TrustGateExecutor::new(MockExecutor, policy);
329        gate.set_effective_trust(TrustLevel::Verified);
330
331        let result = gate
332            .execute_tool_call(&make_call_with_cmd("bash", "echo hi"))
333            .await
334            .unwrap();
335        assert!(result.is_some());
336    }
337
338    #[tokio::test]
339    async fn quarantined_denies_web_scrape() {
340        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
341        gate.set_effective_trust(TrustLevel::Quarantined);
342
343        let result = gate.execute_tool_call(&make_call("web_scrape")).await;
344        assert!(matches!(result, Err(ToolError::Blocked { .. })));
345    }
346
347    #[derive(Debug)]
348    struct EnvCapture {
349        captured: std::sync::Mutex<Option<std::collections::HashMap<String, String>>>,
350    }
351    impl EnvCapture {
352        fn new() -> Self {
353            Self {
354                captured: std::sync::Mutex::new(None),
355            }
356        }
357    }
358    impl ToolExecutor for EnvCapture {
359        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
360            Ok(None)
361        }
362        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
363            Ok(None)
364        }
365        fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
366            *self.captured.lock().unwrap() = env;
367        }
368    }
369
370    #[test]
371    fn set_skill_env_forwarded_to_inner() {
372        let inner = EnvCapture::new();
373        let gate = TrustGateExecutor::new(inner, PermissionPolicy::default());
374
375        let mut env = std::collections::HashMap::new();
376        env.insert("MY_VAR".to_owned(), "42".to_owned());
377        gate.set_skill_env(Some(env.clone()));
378
379        let captured = gate.inner.captured.lock().unwrap();
380        assert_eq!(*captured, Some(env));
381    }
382
383    #[tokio::test]
384    async fn mcp_tool_supervised_no_rules_allows() {
385        // MCP tool with Supervised mode + from_legacy policy (no rules for MCP tool) => Ok
386        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
387        let gate = TrustGateExecutor::new(MockExecutor, policy);
388        gate.set_effective_trust(TrustLevel::Trusted);
389
390        let mut params = serde_json::Map::new();
391        params.insert(
392            "file_path".into(),
393            serde_json::Value::String("/tmp/test.txt".into()),
394        );
395        let call = ToolCall {
396            tool_id: "mcp_filesystem__read_file".into(),
397            params,
398        };
399        let result = gate.execute_tool_call(&call).await;
400        assert!(
401            result.is_ok(),
402            "MCP tool should be allowed when no rules exist"
403        );
404    }
405
406    #[tokio::test]
407    async fn bash_with_explicit_deny_rule_blocked() {
408        // Bash with explicit Deny rule => Err(ToolCallBlocked)
409        let policy = crate::permissions::PermissionPolicy::from_legacy(&["sudo".into()], &[]);
410        let gate = TrustGateExecutor::new(MockExecutor, policy);
411        gate.set_effective_trust(TrustLevel::Trusted);
412
413        let result = gate
414            .execute_tool_call(&make_call_with_cmd("bash", "sudo apt install vim"))
415            .await;
416        assert!(
417            matches!(result, Err(ToolError::Blocked { .. })),
418            "bash with explicit deny rule should be blocked"
419        );
420    }
421
422    #[tokio::test]
423    async fn bash_with_explicit_allow_rule_succeeds() {
424        // Tool with explicit Allow rules => Ok
425        let policy = crate::permissions::PermissionPolicy::from_legacy(&[], &[]);
426        let gate = TrustGateExecutor::new(MockExecutor, policy);
427        gate.set_effective_trust(TrustLevel::Trusted);
428
429        let result = gate
430            .execute_tool_call(&make_call_with_cmd("bash", "echo hello"))
431            .await;
432        assert!(
433            result.is_ok(),
434            "bash with explicit allow rule should succeed"
435        );
436    }
437
438    #[tokio::test]
439    async fn readonly_denies_mcp_tool_not_in_allowlist() {
440        // ReadOnly mode must deny tools not in READONLY_TOOLS, even MCP ones.
441        let policy =
442            crate::permissions::PermissionPolicy::default().with_autonomy(AutonomyLevel::ReadOnly);
443        let gate = TrustGateExecutor::new(MockExecutor, policy);
444        gate.set_effective_trust(TrustLevel::Trusted);
445
446        let result = gate
447            .execute_tool_call(&make_call("mcpls_get_diagnostics"))
448            .await;
449        assert!(
450            matches!(result, Err(ToolError::Blocked { .. })),
451            "ReadOnly mode must deny non-allowlisted tools"
452        );
453    }
454
455    #[test]
456    fn set_effective_trust_interior_mutability() {
457        let gate = TrustGateExecutor::new(MockExecutor, PermissionPolicy::default());
458        assert_eq!(gate.effective_trust(), TrustLevel::Trusted);
459
460        gate.set_effective_trust(TrustLevel::Quarantined);
461        assert_eq!(gate.effective_trust(), TrustLevel::Quarantined);
462
463        gate.set_effective_trust(TrustLevel::Blocked);
464        assert_eq!(gate.effective_trust(), TrustLevel::Blocked);
465
466        gate.set_effective_trust(TrustLevel::Trusted);
467        assert_eq!(gate.effective_trust(), TrustLevel::Trusted);
468    }
469}