Skip to main content

zeph_core/
skill_invoker.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Tool executor that returns a skill body as tool output with trust-aware sanitization.
5//!
6//! [`SkillInvokeExecutor`] implements `invoke_skill` — a native tool the LLM can call to
7//! retrieve and immediately act under a skill's instructions. Unlike `load_skill` (which is
8//! intent-neutral preview), `invoke_skill` carries intent-to-apply semantics: the next turn
9//! is expected to follow the returned skill body.
10//!
11//! The executor applies the same defense-in-depth pipeline as `format_skills_prompt`:
12//! - Non-Trusted bodies pass through [`sanitize_skill_text`].
13//! - Quarantined bodies are additionally wrapped with [`wrap_quarantined`].
14//! - Blocked skills are refused before any body read.
15//! - `args` are always sanitized regardless of trust level (LLM-chosen text).
16//!
17//! `invoke_skill` and `load_skill` are both listed in `QUARANTINE_DENIED`, so when a
18//! Quarantined 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;
24use schemars::JsonSchema;
25use serde::Deserialize;
26use zeph_common::SkillTrustLevel;
27use zeph_skills::prompt::{sanitize_skill_text, wrap_quarantined};
28use zeph_skills::registry::SkillRegistry;
29use zeph_tools::executor::{
30    ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
31};
32use zeph_tools::registry::{InvocationHint, ToolDef};
33
34/// Parameters for the `invoke_skill` tool call.
35#[derive(Debug, Deserialize, JsonSchema)]
36pub struct InvokeSkillParams {
37    /// Exact skill name from the `<other_skills>` catalog.
38    pub skill_name: String,
39    /// Optional free-form arguments forwarded verbatim to the skill body as a trailing
40    /// `<args>…</args>` block. Capped at 4096 characters.
41    #[serde(default)]
42    pub args: String,
43}
44
45/// Tool executor that returns a skill body by name with trust-aware sanitization.
46///
47/// Holds a shared reference to the skill registry and a per-turn trust snapshot
48/// refreshed by the agent loop. Both are cheap `Arc` clones — no allocation on hot path.
49#[derive(Clone, Debug)]
50pub struct SkillInvokeExecutor {
51    registry: Arc<RwLock<SkillRegistry>>,
52    /// Per-skill trust snapshot refreshed once per turn by the agent.
53    /// Absence of an entry means no trust row exists — treat as Quarantined
54    /// (see `SkillTrustLevel::default`).
55    trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustLevel>>>,
56}
57
58impl SkillInvokeExecutor {
59    /// Create a new executor with shared registry and trust snapshot.
60    ///
61    /// Both `Arc`s must be the same instances held by the agent so updates are
62    /// visible without re-constructing the executor.
63    #[must_use]
64    pub fn new(
65        registry: Arc<RwLock<SkillRegistry>>,
66        trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustLevel>>>,
67    ) -> Self {
68        Self {
69            registry,
70            trust_snapshot,
71        }
72    }
73
74    /// Resolve the trust level for a skill from the snapshot.
75    ///
76    /// Returns `SkillTrustLevel::default()` (Quarantined) when no row exists — fail-closed.
77    fn resolve_trust(&self, skill_name: &str) -> SkillTrustLevel {
78        self.trust_snapshot
79            .read()
80            .get(skill_name)
81            .copied()
82            .unwrap_or_default()
83    }
84}
85
86impl ToolExecutor for SkillInvokeExecutor {
87    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
88        Ok(None)
89    }
90
91    fn tool_definitions(&self) -> Vec<ToolDef> {
92        vec![ToolDef {
93            id: "invoke_skill".into(),
94            description: "Invoke a skill by name. Returns the skill body as tool output; the \
95                next turn should act under those instructions. Parameters: \
96                skill_name (required) — exact name from <other_skills>; \
97                args (optional) — <=4096 chars appended as <args>...</args>. \
98                Use when a cataloged skill clearly matches the current task and you \
99                intend to follow it in the next turn."
100                .into(),
101            schema: schemars::schema_for!(InvokeSkillParams),
102            invocation: InvocationHint::ToolCall,
103            output_schema: None,
104        }]
105    }
106
107    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
108        if call.tool_id != "invoke_skill" {
109            return Ok(None);
110        }
111        let params: InvokeSkillParams = deserialize_params(&call.params)?;
112        let skill_name: String = params.skill_name.chars().take(128).collect();
113
114        let trust = self.resolve_trust(&skill_name);
115        // Sanitize skill_name before it appears in any tool output: it originates from the LLM
116        // and could carry injection markers (e.g. `<|im_start|>`).
117        let skill_name_safe = sanitize_skill_text(&skill_name);
118
119        // Blocked skills are refused before any body read — executor defense layer.
120        if trust == SkillTrustLevel::Blocked {
121            return Ok(Some(make_output(format!(
122                "skill is blocked by policy: {skill_name_safe}"
123            ))));
124        }
125
126        // Clone body out of the read guard before any .await — never hold lock across await.
127        let body = {
128            let guard = self.registry.read();
129            guard.body(&skill_name).map(str::to_owned)
130        };
131
132        let summary = match body {
133            Ok(raw_body) => {
134                // Apply the same pipeline as `format_skills_prompt:194-204`:
135                // sanitize for non-Trusted, additionally wrap for Quarantined.
136                let sanitized = if trust == SkillTrustLevel::Trusted {
137                    raw_body
138                } else {
139                    sanitize_skill_text(&raw_body)
140                };
141                let wrapped = if trust == SkillTrustLevel::Quarantined {
142                    wrap_quarantined(&skill_name_safe, &sanitized)
143                } else {
144                    sanitized
145                };
146                let full = if params.args.trim().is_empty() {
147                    wrapped
148                } else {
149                    let args = params.args.chars().take(4096).collect::<String>();
150                    // args originate from LLM text — sanitize regardless of trust.
151                    let args_safe = sanitize_skill_text(&args);
152                    format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
153                };
154                truncate_tool_output(&full)
155            }
156            Err(_) => format!("skill not found: {skill_name_safe}"),
157        };
158
159        Ok(Some(make_output(summary)))
160    }
161}
162
163fn make_output(summary: String) -> ToolOutput {
164    ToolOutput {
165        tool_name: zeph_common::ToolName::new("invoke_skill"),
166        summary,
167        blocks_executed: 1,
168        filter_stats: None,
169        diff: None,
170        streamed: false,
171        terminal_id: None,
172        locations: None,
173        raw_response: None,
174        claim_source: None,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use std::path::Path;
181
182    use super::*;
183
184    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
185        let skill_dir = dir.join(name);
186        std::fs::create_dir_all(&skill_dir).unwrap();
187        std::fs::write(
188            skill_dir.join("SKILL.md"),
189            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
190        )
191        .unwrap();
192        SkillRegistry::load(&[dir.to_path_buf()])
193    }
194
195    fn make_executor(
196        registry: SkillRegistry,
197        trust_map: HashMap<String, SkillTrustLevel>,
198    ) -> SkillInvokeExecutor {
199        SkillInvokeExecutor::new(
200            Arc::new(RwLock::new(registry)),
201            Arc::new(RwLock::new(trust_map)),
202        )
203    }
204
205    fn make_call(skill_name: &str) -> ToolCall {
206        ToolCall {
207            tool_id: zeph_common::ToolName::new("invoke_skill"),
208            params: serde_json::json!({"skill_name": skill_name})
209                .as_object()
210                .unwrap()
211                .clone(),
212            caller_id: None,
213        }
214    }
215
216    fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
217        ToolCall {
218            tool_id: zeph_common::ToolName::new("invoke_skill"),
219            params: serde_json::json!({"skill_name": skill_name, "args": args})
220                .as_object()
221                .unwrap()
222                .clone(),
223            caller_id: None,
224        }
225    }
226
227    #[tokio::test]
228    async fn trusted_skill_returns_body_verbatim() {
229        let dir = tempfile::tempdir().unwrap();
230        let body = "## Instructions\nDo trusted things";
231        let registry = make_registry_with_skill(dir.path(), "my-skill", body);
232        let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
233        let executor = make_executor(registry, trust);
234        let result = executor
235            .execute_tool_call(&make_call("my-skill"))
236            .await
237            .unwrap()
238            .unwrap();
239        assert!(result.summary.contains("## Instructions"));
240        assert!(result.summary.contains("Do trusted things"));
241    }
242
243    #[tokio::test]
244    async fn verified_skill_is_sanitized() {
245        let dir = tempfile::tempdir().unwrap();
246        let body = "Normal body <|im_start|>injected";
247        let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
248        let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
249        let executor = make_executor(registry, trust);
250        let result = executor
251            .execute_tool_call(&make_call("verified-skill"))
252            .await
253            .unwrap()
254            .unwrap();
255        assert!(result.summary.contains("Normal body"));
256        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
257        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
258        assert!(
259            !result
260                .summary
261                .replace("[BLOCKED:<|im_start|>]", "")
262                .contains("<|im_start|>")
263        );
264    }
265
266    #[tokio::test]
267    async fn quarantined_skill_is_sanitized_and_wrapped() {
268        let dir = tempfile::tempdir().unwrap();
269        let body = "Quarantined content";
270        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
271        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
272        let executor = make_executor(registry, trust);
273        let result = executor
274            .execute_tool_call(&make_call("quarantined-skill"))
275            .await
276            .unwrap()
277            .unwrap();
278        assert!(result.summary.contains("QUARANTINED"));
279        assert!(result.summary.contains("Quarantined content"));
280    }
281
282    #[tokio::test]
283    async fn blocked_skill_is_refused_without_body_read() {
284        let dir = tempfile::tempdir().unwrap();
285        let body = "secret body that should not be returned";
286        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
287        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
288        let executor = make_executor(registry, trust);
289        let result = executor
290            .execute_tool_call(&make_call("blocked-skill"))
291            .await
292            .unwrap()
293            .unwrap();
294        assert!(result.summary.contains("blocked by policy"));
295        assert!(!result.summary.contains("secret body"));
296    }
297
298    #[tokio::test]
299    async fn no_trust_row_defaults_to_quarantined_behavior() {
300        // Default trust is Quarantined — fail-closed.
301        let dir = tempfile::tempdir().unwrap();
302        let body = "Some body";
303        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
304        let executor = make_executor(registry, HashMap::new());
305        let result = executor
306            .execute_tool_call(&make_call("unknown-skill"))
307            .await
308            .unwrap()
309            .unwrap();
310        // Quarantined path: body is wrapped.
311        assert!(result.summary.contains("QUARANTINED"));
312    }
313
314    #[tokio::test]
315    async fn nonexistent_skill_returns_not_found() {
316        let dir = tempfile::tempdir().unwrap();
317        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
318        let executor = make_executor(registry, HashMap::new());
319        let result = executor
320            .execute_tool_call(&make_call("nonexistent"))
321            .await
322            .unwrap()
323            .unwrap();
324        assert!(result.summary.contains("skill not found"));
325    }
326
327    #[tokio::test]
328    async fn wrong_tool_id_returns_none() {
329        let dir = tempfile::tempdir().unwrap();
330        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
331        let executor = make_executor(registry, HashMap::new());
332        let call = ToolCall {
333            tool_id: zeph_common::ToolName::new("bash"),
334            params: serde_json::Map::new(),
335            caller_id: None,
336        };
337        let result = executor.execute_tool_call(&call).await.unwrap();
338        assert!(result.is_none());
339    }
340
341    #[tokio::test]
342    async fn execute_always_returns_none() {
343        let dir = tempfile::tempdir().unwrap();
344        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
345        let executor = make_executor(registry, HashMap::new());
346        let result = executor.execute("any text").await.unwrap();
347        assert!(result.is_none());
348    }
349
350    #[tokio::test]
351    async fn args_are_appended_to_trusted_body() {
352        let dir = tempfile::tempdir().unwrap();
353        let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
354        let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
355        let executor = make_executor(registry, trust);
356        let result = executor
357            .execute_tool_call(&make_call_with_args("argskill", "user arg"))
358            .await
359            .unwrap()
360            .unwrap();
361        assert!(result.summary.contains("Body text"));
362        assert!(result.summary.contains("<args>"));
363        assert!(result.summary.contains("user arg"));
364    }
365
366    #[tokio::test]
367    async fn args_are_sanitized_regardless_of_trust() {
368        let dir = tempfile::tempdir().unwrap();
369        let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
370        let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
371        let executor = make_executor(registry, trust);
372        let result = executor
373            .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
374            .await
375            .unwrap()
376            .unwrap();
377        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
378        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
379        assert!(
380            !result
381                .summary
382                .replace("[BLOCKED:<|im_start|>]", "")
383                .contains("<|im_start|>")
384        );
385    }
386
387    #[tokio::test]
388    async fn tool_definitions_returns_invoke_skill() {
389        let dir = tempfile::tempdir().unwrap();
390        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
391        let executor = make_executor(registry, HashMap::new());
392        let defs = executor.tool_definitions();
393        assert_eq!(defs.len(), 1);
394        assert_eq!(defs[0].id.as_ref(), "invoke_skill");
395    }
396}