Skip to main content

zeph_core/
skill_trust_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared trust-gating pipeline for skill-body tool executors.
5//!
6//! [`SkillTrustGate`] is the single implementation of the trust pipeline documented in
7//! [`crate::skill_invoker`]: refuse `Blocked` skills before any body read, run the optional
8//! per-invocation blake3 integrity re-check, sanitize non-Trusted bodies, and wrap Quarantined
9//! bodies. Both `load_skill` ([`crate::skill_loader::SkillLoaderExecutor`]) and `invoke_skill`
10//! ([`crate::skill_invoker::SkillInvokeExecutor`]) hold their own `SkillTrustGate` built from
11//! the *same* `trust_snapshot` `Arc` (see `agent_setup::build_skill_executors` in the binary
12//! crate), so the two tools cannot drift apart and observe identical trust state within a turn
13//! (#6049, #6050).
14//!
15//! [`SkillTrustGate`] and [`resolve_body`](SkillTrustGate::resolve_body) are also `pub` at the
16//! crate root so the binary crate's `zeph skill invoke` CLI preview command can route through
17//! the exact same pipeline instead of maintaining its own copy (#6079).
18
19use std::collections::HashMap;
20use std::sync::Arc;
21
22use parking_lot::RwLock;
23use zeph_common::SkillTrustLevel;
24use zeph_skills::prompt::{sanitize_skill_text, wrap_quarantined};
25use zeph_skills::registry::SkillRegistry;
26use zeph_skills::trust::compute_skill_hash;
27use zeph_tools::executor::ToolError;
28
29use crate::skill_invoker::SkillTrustSnapshot;
30
31/// Outcome of resolving a skill body through [`SkillTrustGate::resolve_body`].
32///
33/// Callers match on this to apply their own tool-specific output framing (e.g. `invoke_skill`
34/// appends an `<args>` block to `Body`) before truncating for the LLM.
35#[derive(Debug)]
36pub enum SkillBodyResolution {
37    /// Refused by policy (blocked) or a failed integrity check — ready-to-return tool summary.
38    Refused(String),
39    /// `skill_name` has no entry in the registry — ready-to-return tool summary.
40    NotFound(String),
41    /// Body resolved and gated (sanitized for non-Trusted, wrapped for Quarantined). Not yet
42    /// truncated — callers append any additional framing first, then call
43    /// [`truncate_tool_output`](zeph_tools::executor::truncate_tool_output).
44    Body(String),
45}
46
47/// Shared registry + trust-snapshot pair backing both skill-body tool executors.
48///
49/// Cloning is cheap — both fields are `Arc`s. Construct one instance per executor from the same
50/// `trust_snapshot` `Arc` so `load_skill` and `invoke_skill` see identical trust state within a
51/// turn.
52#[derive(Clone, Debug)]
53pub struct SkillTrustGate {
54    registry: Arc<RwLock<SkillRegistry>>,
55    trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
56}
57
58impl SkillTrustGate {
59    /// Build a gate over `registry` and `trust_snapshot`.
60    ///
61    /// `trust_snapshot` should be the same `Arc` shared with any other trust-aware skill-body
62    /// consumer (see `agent_setup::build_skill_executors` in the binary crate) so they all
63    /// observe identical trust state.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use std::collections::HashMap;
69    /// use std::sync::Arc;
70    ///
71    /// use parking_lot::RwLock;
72    /// use zeph_core::SkillTrustGate;
73    /// use zeph_skills::registry::SkillRegistry;
74    ///
75    /// let registry = Arc::new(RwLock::new(SkillRegistry::empty()));
76    /// let trust_snapshot = Arc::new(RwLock::new(HashMap::new()));
77    /// let _gate = SkillTrustGate::new(registry, trust_snapshot);
78    /// ```
79    pub fn new(
80        registry: Arc<RwLock<SkillRegistry>>,
81        trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
82    ) -> Self {
83        Self {
84            registry,
85            trust_snapshot,
86        }
87    }
88
89    /// Resolve the trust snapshot entry for a skill.
90    ///
91    /// Returns `None` when no row exists — [`resolve_body`](Self::resolve_body) treats absence
92    /// as `SkillTrustLevel::MISSING_ENTRY_FALLBACK` (Trusted), not Quarantined.
93    fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
94        self.trust_snapshot.read().get(skill_name).cloned()
95    }
96
97    /// Run the per-invocation blake3 integrity check.
98    ///
99    /// Returns `Some(message)` when the invocation must be aborted (hash mismatch, empty stored
100    /// hash, missing skill dir, or IO error). Returns `None` when the check passes and dispatch
101    /// should proceed.
102    async fn check_integrity(
103        &self,
104        skill_name: &str,
105        skill_name_safe: &str,
106        entry: &SkillTrustSnapshot,
107    ) -> Result<Option<String>, ToolError> {
108        if entry.blake3_hash.is_empty() {
109            tracing::warn!(
110                skill = %skill_name,
111                "requires_trust_check is set but no stored hash found, aborting invocation"
112            );
113            return Ok(Some(format!(
114                "skill integrity check failed: {skill_name_safe} \
115                 — requires_trust_check is set but no stored hash found"
116            )));
117        }
118        let stored_hash = entry.blake3_hash.clone();
119        let skill_dir = {
120            let guard = self.registry.read();
121            guard.skill_dir(skill_name)
122        };
123        let Some(dir) = skill_dir else {
124            tracing::warn!(
125                skill = %skill_name,
126                "requires_trust_check: skill_dir not found, aborting invocation"
127            );
128            return Ok(Some(format!(
129                "skill integrity check failed: {skill_name_safe} — skill directory not found"
130            )));
131        };
132        let current_hash = tokio::task::spawn_blocking(move || compute_skill_hash(&dir))
133            .await
134            .map_err(|e| ToolError::InvalidParams {
135                message: format!("spawn_blocking join error: {e}"),
136            })?;
137        match current_hash {
138            Ok(hash) if hash != stored_hash => {
139                tracing::warn!(
140                    skill = %skill_name,
141                    "hash mismatch on per-invocation check, demoting to Quarantined"
142                );
143                self.trust_snapshot
144                    .write()
145                    .entry(skill_name.to_owned())
146                    .and_modify(|e| e.trust_level = SkillTrustLevel::Quarantined);
147                // TODO: persist demotion to trust store (#4293 follow-up)
148                Ok(Some(format!(
149                    "skill integrity check failed: {skill_name_safe} — demoted to Quarantined"
150                )))
151            }
152            Err(e) => {
153                tracing::warn!(
154                    skill = %skill_name,
155                    err = %e,
156                    "failed to re-hash skill, aborting invocation"
157                );
158                Ok(Some(format!(
159                    "skill integrity check failed: {skill_name_safe} — cannot read SKILL.md"
160                )))
161            }
162            Ok(_) => Ok(None), // hash matches, proceed
163        }
164    }
165
166    /// Resolve `skill_name` through the trust pipeline shared by `load_skill` and
167    /// `invoke_skill`: refuse `Blocked` before any body read, re-check integrity when
168    /// `requires_trust_check` is set, then sanitize/wrap the body per trust level.
169    ///
170    /// A missing trust-snapshot row resolves to `SkillTrustLevel::MISSING_ENTRY_FALLBACK`
171    /// (Trusted) — "never classified", not "known untrusted". `skill_name` is sanitized before
172    /// it appears in any returned message, including the not-found path.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error only when the `requires_trust_check` integrity re-check's
177    /// `spawn_blocking` task panics or is cancelled — a hash mismatch, missing skill directory,
178    /// or unreadable `SKILL.md` are reported as `Ok(SkillBodyResolution::Refused(_))`, not `Err`.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// # use std::collections::HashMap;
184    /// # use std::sync::Arc;
185    /// # use parking_lot::RwLock;
186    /// # use zeph_core::{SkillBodyResolution, SkillTrustGate};
187    /// # use zeph_skills::registry::SkillRegistry;
188    /// # #[tokio::main] async fn main() {
189    /// let registry = Arc::new(RwLock::new(SkillRegistry::empty()));
190    /// let trust_snapshot = Arc::new(RwLock::new(HashMap::new()));
191    /// let gate = SkillTrustGate::new(registry, trust_snapshot);
192    ///
193    /// match gate.resolve_body("nonexistent").await.unwrap() {
194    ///     SkillBodyResolution::NotFound(message) => assert!(message.contains("nonexistent")),
195    ///     _ => panic!("expected NotFound for an empty registry"),
196    /// }
197    /// # }
198    /// ```
199    pub async fn resolve_body(&self, skill_name: &str) -> Result<SkillBodyResolution, ToolError> {
200        let snapshot = self.resolve_snapshot(skill_name);
201        let trust = snapshot
202            .as_ref()
203            .map_or(SkillTrustLevel::MISSING_ENTRY_FALLBACK, |s| s.trust_level);
204        // Sanitize skill_name before it appears in any tool output: it originates from the LLM
205        // and could carry injection markers (e.g. `<|im_start|>`).
206        let skill_name_safe = sanitize_skill_text(skill_name);
207
208        // Blocked skills are refused before any body read — executor defense layer.
209        if trust == SkillTrustLevel::Blocked {
210            return Ok(SkillBodyResolution::Refused(format!(
211                "skill is blocked by policy: {skill_name_safe}"
212            )));
213        }
214
215        // Per-invocation integrity check: re-hash SKILL.md when requires_trust_check is set.
216        if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check)
217            && let Some(message) = self
218                .check_integrity(skill_name, &skill_name_safe, entry)
219                .await?
220        {
221            return Ok(SkillBodyResolution::Refused(message));
222        }
223
224        // Clone body out of the read guard before any further await — never hold lock across await.
225        let body = {
226            let guard = self.registry.read();
227            guard.body(skill_name).map(str::to_owned)
228        };
229
230        match body {
231            Ok(raw_body) => {
232                // Apply the same pipeline as `format_skills_prompt`: sanitize for non-Trusted,
233                // additionally wrap for Quarantined.
234                let sanitized = if trust == SkillTrustLevel::Trusted {
235                    raw_body
236                } else {
237                    sanitize_skill_text(&raw_body)
238                };
239                let wrapped = if trust == SkillTrustLevel::Quarantined {
240                    wrap_quarantined(&skill_name_safe, &sanitized)
241                } else {
242                    sanitized
243                };
244                Ok(SkillBodyResolution::Body(wrapped))
245            }
246            Err(_) => Ok(SkillBodyResolution::NotFound(format!(
247                "skill not found: {skill_name_safe}"
248            ))),
249        }
250    }
251}
252
253/// Single source of truth for the `requires_trust_check` arming decision made on promotion to
254/// `Trusted`/`Verified` (#6087).
255///
256/// `force_on` (`--require-check`) always wins; otherwise `force_off` (`--no-require-check`)
257/// wins; otherwise falls back to `config_default`
258/// (`[skills.trust] require_integrity_check_on_promote`). Used identically by the CLI
259/// (`zeph skill trust`, binary crate) and in-session (`/skill trust`,
260/// `crate::agent::trust_commands`) promotion handlers — both already gate the call on
261/// `matches!(level, SkillTrustLevel::Trusted | SkillTrustLevel::Verified)` before consulting
262/// this function; promotion to `Quarantined`/`Blocked` must never call it.
263///
264/// # Examples
265///
266/// ```
267/// use zeph_core::resolve_require_check;
268///
269/// assert!(resolve_require_check(true, true, false), "force_on always wins");
270/// assert!(!resolve_require_check(false, true, true), "force_off wins over the default");
271/// assert!(resolve_require_check(false, false, true), "falls back to the config default");
272/// ```
273#[must_use]
274pub fn resolve_require_check(force_on: bool, force_off: bool, config_default: bool) -> bool {
275    if force_on {
276        true
277    } else if force_off {
278        false
279    } else {
280        config_default
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use std::path::Path;
287
288    use zeph_skills::trust::compute_skill_hash;
289
290    use super::*;
291
292    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
293        let skill_dir = dir.join(name);
294        std::fs::create_dir_all(&skill_dir).unwrap();
295        std::fs::write(
296            skill_dir.join("SKILL.md"),
297            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
298        )
299        .unwrap();
300        SkillRegistry::load(&[dir.to_path_buf()])
301    }
302
303    fn make_gate(
304        registry: SkillRegistry,
305        snapshots: HashMap<String, SkillTrustSnapshot>,
306    ) -> SkillTrustGate {
307        SkillTrustGate::new(
308            Arc::new(RwLock::new(registry)),
309            Arc::new(RwLock::new(snapshots)),
310        )
311    }
312
313    // Exercises the gate directly (bypassing `SkillLoaderExecutor`/`SkillInvokeExecutor`) to
314    // guard the pipeline the CLI's `zeph skill invoke` now shares with the agent tools (#6079).
315
316    #[tokio::test]
317    async fn blocked_skill_refused_without_body_read() {
318        let dir = tempfile::tempdir().unwrap();
319        let body = "secret body that must not leak";
320        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
321        let snapshots = HashMap::from([(
322            "blocked-skill".to_owned(),
323            SkillTrustSnapshot {
324                trust_level: SkillTrustLevel::Blocked,
325                requires_trust_check: false,
326                blake3_hash: String::new(),
327            },
328        )]);
329        let gate = make_gate(registry, snapshots);
330        match gate.resolve_body("blocked-skill").await.unwrap() {
331            SkillBodyResolution::Refused(message) => {
332                assert!(message.contains("blocked by policy"));
333                assert!(!message.contains("secret body"));
334            }
335            other => panic!("expected Refused, got a different variant: {other:?}"),
336        }
337    }
338
339    #[tokio::test]
340    async fn not_found_sanitizes_skill_name() {
341        let dir = tempfile::tempdir().unwrap();
342        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
343        let gate = make_gate(registry, HashMap::new());
344        match gate.resolve_body("<|im_start|>nonexistent").await.unwrap() {
345            SkillBodyResolution::NotFound(message) => {
346                assert!(message.contains("skill not found"));
347                assert!(message.contains("[BLOCKED:<|im_start|>]"));
348                assert!(
349                    !message
350                        .replace("[BLOCKED:<|im_start|>]", "")
351                        .contains("<|im_start|>")
352                );
353            }
354            other => panic!("expected NotFound, got a different variant: {other:?}"),
355        }
356    }
357
358    #[tokio::test]
359    async fn requires_trust_check_hash_match_returns_body() {
360        let dir = tempfile::tempdir().unwrap();
361        let body = "trusted content";
362        let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
363        let hash = compute_skill_hash(&dir.path().join("checked-skill")).unwrap();
364        let snapshots = HashMap::from([(
365            "checked-skill".to_owned(),
366            SkillTrustSnapshot {
367                trust_level: SkillTrustLevel::Trusted,
368                requires_trust_check: true,
369                blake3_hash: hash,
370            },
371        )]);
372        let gate = make_gate(registry, snapshots);
373        match gate.resolve_body("checked-skill").await.unwrap() {
374            SkillBodyResolution::Body(returned) => assert!(returned.contains(body)),
375            other => panic!("expected Body, got a different variant: {other:?}"),
376        }
377    }
378
379    #[tokio::test]
380    async fn requires_trust_check_hash_mismatch_demotes_and_refuses() {
381        let dir = tempfile::tempdir().unwrap();
382        let body = "content that changed after install";
383        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
384        let snapshots = HashMap::from([(
385            "tampered-skill".to_owned(),
386            SkillTrustSnapshot {
387                trust_level: SkillTrustLevel::Trusted,
388                requires_trust_check: true,
389                blake3_hash: "0".repeat(64),
390            },
391        )]);
392        let trust_snapshot = Arc::new(RwLock::new(snapshots));
393        let gate =
394            SkillTrustGate::new(Arc::new(RwLock::new(registry)), Arc::clone(&trust_snapshot));
395        match gate.resolve_body("tampered-skill").await.unwrap() {
396            SkillBodyResolution::Refused(message) => {
397                assert!(message.contains("demoted to Quarantined"));
398                assert!(!message.contains(body));
399            }
400            other => panic!("expected Refused, got a different variant: {other:?}"),
401        }
402        assert_eq!(
403            trust_snapshot
404                .read()
405                .get("tampered-skill")
406                .unwrap()
407                .trust_level,
408            SkillTrustLevel::Quarantined,
409            "in-memory snapshot must reflect the demotion for subsequent calls this turn"
410        );
411    }
412
413    #[tokio::test]
414    async fn missing_snapshot_defaults_to_trusted() {
415        let dir = tempfile::tempdir().unwrap();
416        let body = "unclassified skill body";
417        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
418        let gate = make_gate(registry, HashMap::new());
419        match gate.resolve_body("unknown-skill").await.unwrap() {
420            SkillBodyResolution::Body(returned) => {
421                assert!(!returned.contains("QUARANTINED"));
422                assert!(returned.contains(body));
423            }
424            other => panic!("expected Body, got a different variant: {other:?}"),
425        }
426    }
427
428    // ── resolve_require_check (#6087) ────────────────────────────────────────
429
430    #[test]
431    fn resolve_require_check_defaults_to_config_when_no_flag_forces_it() {
432        assert!(resolve_require_check(false, false, true));
433        assert!(!resolve_require_check(false, false, false));
434    }
435
436    #[test]
437    fn resolve_require_check_force_on_wins_over_config_default_false() {
438        assert!(resolve_require_check(true, false, false));
439    }
440
441    #[test]
442    fn resolve_require_check_force_off_wins_over_config_default_true() {
443        assert!(!resolve_require_check(false, true, true));
444    }
445
446    #[test]
447    fn resolve_require_check_force_on_wins_over_force_off() {
448        // Both flags present is nonsensical (clap rejects it on the CLI via conflicts_with),
449        // but the in-session parser has no such enforcement — force_on must still take
450        // precedence so the decision is total and unambiguous either way.
451        assert!(resolve_require_check(true, true, false));
452        assert!(resolve_require_check(true, true, true));
453    }
454}