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}