Skip to main content

zeph_core/
skill_loader.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Tool executor that loads a full skill body by name, gated by the same trust-aware pipeline
5//! as `invoke_skill`.
6//!
7//! [`SkillLoaderExecutor`] implements `load_skill` — a native tool the LLM can call to preview a
8//! skill's full body without committing to follow it (unlike `invoke_skill`, which carries
9//! intent-to-apply semantics). Both tools share a
10//! `SkillTrustGate` (crate-private) built from the same
11//! `trust_snapshot` `Arc`, so they observe identical trust state within a turn:
12//! - Non-Trusted bodies pass through [`sanitize_skill_text`](zeph_skills::prompt::sanitize_skill_text).
13//! - Quarantined bodies are additionally wrapped with [`wrap_quarantined`](zeph_skills::prompt::wrap_quarantined).
14//! - Blocked skills are refused before any body read.
15//! - `skill_name` is sanitized before it appears in any output path (found, blocked, not-found).
16//!
17//! `load_skill` and `invoke_skill` are both listed in `QUARANTINE_DENIED`, so when a Quarantined
18//! skill is active the trust gate refuses both before this executor is reached.
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use parking_lot::RwLock;
24
25use schemars::JsonSchema;
26use serde::Deserialize;
27use zeph_skills::registry::SkillRegistry;
28use zeph_tools::executor::{
29    ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
30};
31use zeph_tools::registry::{InvocationHint, ToolDef};
32
33use crate::skill_invoker::SkillTrustSnapshot;
34use crate::skill_trust_gate::{SkillBodyResolution, SkillTrustGate};
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub struct LoadSkillParams {
38    /// Name of the skill to load (from `<other_skills>` catalog).
39    pub skill_name: String,
40}
41
42/// Tool executor that loads a full skill body by name from the shared registry.
43///
44/// Delegates trust resolution, integrity checking, sanitization, and quarantine wrapping to a
45/// shared `SkillTrustGate` (crate-private) — the same pipeline `invoke_skill`
46/// ([`crate::skill_invoker::SkillInvokeExecutor`]) uses, so the two tools cannot drift apart.
47#[derive(Clone, Debug)]
48pub struct SkillLoaderExecutor {
49    gate: SkillTrustGate,
50}
51
52impl SkillLoaderExecutor {
53    /// Create a new executor with shared registry and trust snapshot.
54    ///
55    /// `trust_snapshot` must be the same `Arc` shared with `SkillInvokeExecutor` (see
56    /// `agent_setup::build_skill_executors` in the binary crate) so `load_skill` and
57    /// `invoke_skill` see identical trust state within a turn.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use std::collections::HashMap;
63    /// use std::sync::Arc;
64    ///
65    /// use parking_lot::RwLock;
66    /// use zeph_core::SkillLoaderExecutor;
67    /// use zeph_skills::registry::SkillRegistry;
68    ///
69    /// let registry = Arc::new(RwLock::new(SkillRegistry::empty()));
70    /// let trust_snapshot = Arc::new(RwLock::new(HashMap::new()));
71    /// let _executor = SkillLoaderExecutor::new(registry, trust_snapshot);
72    /// ```
73    #[must_use]
74    pub fn new(
75        registry: Arc<RwLock<SkillRegistry>>,
76        trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
77    ) -> Self {
78        Self {
79            gate: SkillTrustGate::new(registry, trust_snapshot),
80        }
81    }
82
83    /// Wires the shared per-turn trust floor (#6701) so a `load_skill` preview of a
84    /// Quarantined body folds the turn's trust down for its remainder — see
85    /// [`SkillTrustGate::with_turn_trust_floor`].
86    #[must_use]
87    pub fn with_turn_trust_floor(mut self, turn_trust_floor: zeph_common::TurnTrustFloor) -> Self {
88        self.gate = self.gate.with_turn_trust_floor(turn_trust_floor);
89        self
90    }
91}
92
93impl ToolExecutor for SkillLoaderExecutor {
94    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
95        Ok(None)
96    }
97
98    fn tool_definitions(&self) -> Vec<ToolDef> {
99        vec![ToolDef {
100            id: "load_skill".into(),
101            description: "Load the full body of a skill by name when you see a relevant entry in the <other_skills> catalog.\n\nParameters: name (string, required) - exact skill name from the <other_skills> catalog\nReturns: complete skill instructions (SKILL.md body), or error if skill not found\nErrors: InvalidParams if name is empty; Execution if skill not found in registry\nExample: {\"name\": \"code-review\"}".into(),
102            schema: schemars::schema_for!(LoadSkillParams),
103            invocation: InvocationHint::ToolCall,
104            output_schema: None,
105            server_id: None,
106        }]
107    }
108
109    #[tracing::instrument(name = "core.skill_loader.execute", skip_all, fields(skill = tracing::field::Empty))]
110    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
111        if call.tool_id != "load_skill" {
112            return Ok(None);
113        }
114        let params: LoadSkillParams = deserialize_params(&call.params)?;
115        let skill_name: String = params.skill_name.chars().take(128).collect();
116
117        tracing::Span::current().record("skill", skill_name.as_str());
118
119        let summary = match self.gate.resolve_body(&skill_name).await? {
120            SkillBodyResolution::Refused(message) | SkillBodyResolution::NotFound(message) => {
121                message
122            }
123            SkillBodyResolution::Body(wrapped) => truncate_tool_output(&wrapped),
124        };
125
126        Ok(Some(ToolOutput {
127            tool_name: zeph_common::ToolName::new("load_skill"),
128            summary,
129            blocks_executed: 1,
130            filter_stats: None,
131            diff: None,
132            streamed: false,
133            terminal_id: None,
134            locations: None,
135            raw_response: None,
136            claim_source: None,
137            ..Default::default()
138        }))
139    }
140
141    zeph_tools::tool_executor_no_inner_defaults!();
142}
143
144#[cfg(test)]
145mod tests {
146    use std::path::Path;
147
148    use zeph_common::SkillTrustLevel;
149
150    use super::*;
151
152    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
153        let skill_dir = dir.join(name);
154        std::fs::create_dir_all(&skill_dir).unwrap();
155        std::fs::write(
156            skill_dir.join("SKILL.md"),
157            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
158        )
159        .unwrap();
160        SkillRegistry::load(&[dir.to_path_buf()])
161    }
162
163    fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
164        SkillTrustSnapshot {
165            trust_level: level,
166            requires_trust_check: false,
167            blake3_hash: String::new(),
168        }
169    }
170
171    /// `trust_map` is `None` to exercise the missing-row-defaults-to-Trusted path.
172    fn make_executor(
173        registry: SkillRegistry,
174        trust_map: HashMap<String, SkillTrustLevel>,
175    ) -> SkillLoaderExecutor {
176        let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
177            .into_iter()
178            .map(|(k, v)| (k, make_snapshot(v)))
179            .collect();
180        SkillLoaderExecutor::new(
181            Arc::new(RwLock::new(registry)),
182            Arc::new(RwLock::new(snapshot_map)),
183        )
184    }
185
186    fn make_call(skill_name: &str) -> ToolCall {
187        ToolCall {
188            tool_id: zeph_common::ToolName::new("load_skill"),
189            params: serde_json::json!({"skill_name": skill_name})
190                .as_object()
191                .unwrap()
192                .clone(),
193            caller_id: None,
194            context: None,
195
196            tool_call_id: String::new(),
197            skill_name: None,
198        }
199    }
200
201    #[tokio::test]
202    async fn load_existing_skill_returns_body() {
203        let dir = tempfile::tempdir().unwrap();
204        let registry =
205            make_registry_with_skill(dir.path(), "git-commit", "## Instructions\nDo git stuff");
206        let executor = make_executor(registry, HashMap::new());
207        let result = executor
208            .execute_tool_call(&make_call("git-commit"))
209            .await
210            .unwrap()
211            .unwrap();
212        assert!(result.summary.contains("## Instructions"));
213        assert!(result.summary.contains("Do git stuff"));
214    }
215
216    #[tokio::test]
217    async fn load_nonexistent_skill_returns_error_message() {
218        let dir = tempfile::tempdir().unwrap();
219        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
220        let executor = make_executor(registry, HashMap::new());
221        let result = executor
222            .execute_tool_call(&make_call("nonexistent"))
223            .await
224            .unwrap()
225            .unwrap();
226        assert!(result.summary.contains("skill not found"));
227        assert!(result.summary.contains("nonexistent"));
228    }
229
230    #[test]
231    fn tool_definitions_returns_load_skill() {
232        let dir = tempfile::tempdir().unwrap();
233        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
234        let executor = make_executor(registry, HashMap::new());
235        let defs = executor.tool_definitions();
236        assert_eq!(defs.len(), 1);
237        assert_eq!(defs[0].id.as_ref(), "load_skill");
238    }
239
240    #[tokio::test]
241    async fn execute_returns_none_for_wrong_tool_id() {
242        let dir = tempfile::tempdir().unwrap();
243        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
244        let executor = make_executor(registry, HashMap::new());
245        let call = ToolCall {
246            tool_id: zeph_common::ToolName::new("bash"),
247            params: serde_json::Map::new(),
248            caller_id: None,
249            context: None,
250
251            tool_call_id: String::new(),
252            skill_name: None,
253        };
254        let result = executor.execute_tool_call(&call).await.unwrap();
255        assert!(result.is_none());
256    }
257
258    #[tokio::test]
259    async fn long_skill_body_is_truncated() {
260        use zeph_tools::executor::MAX_TOOL_OUTPUT_CHARS;
261        let dir = tempfile::tempdir().unwrap();
262        let long_body = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
263        let registry = make_registry_with_skill(dir.path(), "big-skill", &long_body);
264        let executor = make_executor(registry, HashMap::new());
265        let result = executor
266            .execute_tool_call(&make_call("big-skill"))
267            .await
268            .unwrap()
269            .unwrap();
270        assert!(result.summary.contains("truncated"));
271        assert!(result.summary.len() < long_body.len() + 200);
272    }
273
274    #[tokio::test]
275    async fn empty_registry_returns_error_message() {
276        let dir = tempfile::tempdir().unwrap();
277        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
278        let executor = make_executor(registry, HashMap::new());
279        let result = executor
280            .execute_tool_call(&make_call("any"))
281            .await
282            .unwrap()
283            .unwrap();
284        assert!(result.summary.contains("skill not found"));
285    }
286
287    // GAP-1: direct execute() always returns None
288    #[tokio::test]
289    async fn execute_always_returns_none() {
290        let dir = tempfile::tempdir().unwrap();
291        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
292        let executor = make_executor(registry, HashMap::new());
293        let result = executor.execute("any response text").await.unwrap();
294        assert!(result.is_none());
295    }
296
297    // GAP-2: concurrent reads all succeed
298    #[tokio::test]
299    async fn concurrent_execute_tool_call_succeeds() {
300        let dir = tempfile::tempdir().unwrap();
301        let registry =
302            make_registry_with_skill(dir.path(), "shared-skill", "## Concurrent test body");
303        let executor = Arc::new(make_executor(registry, HashMap::new()));
304
305        let handles: Vec<_> = (0..8)
306            .map(|_| {
307                let ex = Arc::clone(&executor);
308                tokio::spawn(async move { ex.execute_tool_call(&make_call("shared-skill")).await })
309            })
310            .collect();
311
312        for h in handles {
313            let result = h.await.unwrap().unwrap().unwrap();
314            assert!(result.summary.contains("## Concurrent test body"));
315        }
316    }
317
318    // GAP-3: empty skill_name returns "not found"
319    #[tokio::test]
320    async fn empty_skill_name_returns_not_found() {
321        let dir = tempfile::tempdir().unwrap();
322        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
323        let executor = make_executor(registry, HashMap::new());
324        let result = executor
325            .execute_tool_call(&make_call(""))
326            .await
327            .unwrap()
328            .unwrap();
329        assert!(result.summary.contains("skill not found"));
330    }
331
332    // GAP-4: missing skill_name field returns ToolError from deserialize_params
333    #[tokio::test]
334    async fn missing_skill_name_field_returns_error() {
335        let dir = tempfile::tempdir().unwrap();
336        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
337        let executor = make_executor(registry, HashMap::new());
338        let call = ToolCall {
339            tool_id: zeph_common::ToolName::new("load_skill"),
340            params: serde_json::Map::new(),
341            caller_id: None,
342            context: None,
343
344            tool_call_id: String::new(),
345            skill_name: None,
346        };
347        let result = executor.execute_tool_call(&call).await;
348        assert!(result.is_err());
349    }
350
351    // ── Trust-gating tests (#6050) ──────────────────────────────────────────
352
353    #[tokio::test]
354    async fn blocked_skill_is_refused_without_body_read() {
355        let dir = tempfile::tempdir().unwrap();
356        let body = "secret body that should not be returned";
357        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
358        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
359        let executor = make_executor(registry, trust);
360        let result = executor
361            .execute_tool_call(&make_call("blocked-skill"))
362            .await
363            .unwrap()
364            .unwrap();
365        assert!(result.summary.contains("blocked by policy"));
366        assert!(!result.summary.contains("secret body"));
367    }
368
369    #[tokio::test]
370    async fn verified_skill_is_sanitized() {
371        let dir = tempfile::tempdir().unwrap();
372        let body = "Normal body <|im_start|>injected";
373        let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
374        let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
375        let executor = make_executor(registry, trust);
376        let result = executor
377            .execute_tool_call(&make_call("verified-skill"))
378            .await
379            .unwrap()
380            .unwrap();
381        assert!(result.summary.contains("Normal body"));
382        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
383        assert!(
384            !result
385                .summary
386                .replace("[BLOCKED:<|im_start|>]", "")
387                .contains("<|im_start|>")
388        );
389    }
390
391    #[tokio::test]
392    async fn quarantined_skill_is_sanitized_and_wrapped() {
393        let dir = tempfile::tempdir().unwrap();
394        let body = "Quarantined content";
395        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
396        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
397        let executor = make_executor(registry, trust);
398        let result = executor
399            .execute_tool_call(&make_call("quarantined-skill"))
400            .await
401            .unwrap()
402            .unwrap();
403        assert!(result.summary.contains("QUARANTINED"));
404        assert!(result.summary.contains("Quarantined content"));
405    }
406
407    #[tokio::test]
408    async fn trusted_skill_returns_body_verbatim() {
409        let dir = tempfile::tempdir().unwrap();
410        let body = "## Instructions\nDo trusted things";
411        let registry = make_registry_with_skill(dir.path(), "trusted-skill", body);
412        let trust = HashMap::from([("trusted-skill".to_owned(), SkillTrustLevel::Trusted)]);
413        let executor = make_executor(registry, trust);
414        let result = executor
415            .execute_tool_call(&make_call("trusted-skill"))
416            .await
417            .unwrap()
418            .unwrap();
419        assert!(result.summary.contains("## Instructions"));
420        assert!(result.summary.contains("Do trusted things"));
421    }
422
423    #[tokio::test]
424    async fn no_trust_row_defaults_to_trusted_behavior() {
425        // A missing trust-map entry means "never classified yet", not "known untrusted" — it
426        // must resolve to Trusted (SkillTrustLevel::MISSING_ENTRY_FALLBACK). Falling back to
427        // Quarantined here would spuriously wrap legitimate skills whenever the trust map is
428        // transiently empty.
429        let dir = tempfile::tempdir().unwrap();
430        let body = "Some body";
431        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
432        let executor = make_executor(registry, HashMap::new());
433        let result = executor
434            .execute_tool_call(&make_call("unknown-skill"))
435            .await
436            .unwrap()
437            .unwrap();
438        assert!(!result.summary.contains("QUARANTINED"));
439        assert!(result.summary.contains(body));
440    }
441
442    #[tokio::test]
443    async fn not_found_error_sanitizes_skill_name() {
444        let dir = tempfile::tempdir().unwrap();
445        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
446        let executor = make_executor(registry, HashMap::new());
447        let result = executor
448            .execute_tool_call(&make_call("<|im_start|>nonexistent"))
449            .await
450            .unwrap()
451            .unwrap();
452        assert!(result.summary.contains("skill not found"));
453        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
454        assert!(
455            !result
456                .summary
457                .replace("[BLOCKED:<|im_start|>]", "")
458                .contains("<|im_start|>")
459        );
460    }
461
462    #[tokio::test]
463    async fn tampered_requires_trust_check_skill_is_caught() {
464        let dir = tempfile::tempdir().unwrap();
465        let body = "## Original body";
466        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
467        let snapshots = HashMap::from([(
468            "tampered-skill".to_owned(),
469            SkillTrustSnapshot {
470                trust_level: SkillTrustLevel::Trusted,
471                requires_trust_check: true,
472                blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
473                    .to_owned(),
474            },
475        )]);
476        let executor = SkillLoaderExecutor::new(
477            Arc::new(RwLock::new(registry)),
478            Arc::new(RwLock::new(snapshots)),
479        );
480        let result = executor
481            .execute_tool_call(&make_call("tampered-skill"))
482            .await
483            .unwrap()
484            .unwrap();
485        assert!(
486            result.summary.contains("demoted to Quarantined"),
487            "output must mention demotion: {}",
488            result.summary
489        );
490        assert!(
491            !result.summary.contains("Original body"),
492            "body must not be returned on hash mismatch: {}",
493            result.summary
494        );
495    }
496}