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            context: None,
214
215            tool_call_id: String::new(),
216        }
217    }
218
219    fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
220        ToolCall {
221            tool_id: zeph_common::ToolName::new("invoke_skill"),
222            params: serde_json::json!({"skill_name": skill_name, "args": args})
223                .as_object()
224                .unwrap()
225                .clone(),
226            caller_id: None,
227            context: None,
228
229            tool_call_id: String::new(),
230        }
231    }
232
233    #[tokio::test]
234    async fn trusted_skill_returns_body_verbatim() {
235        let dir = tempfile::tempdir().unwrap();
236        let body = "## Instructions\nDo trusted things";
237        let registry = make_registry_with_skill(dir.path(), "my-skill", body);
238        let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
239        let executor = make_executor(registry, trust);
240        let result = executor
241            .execute_tool_call(&make_call("my-skill"))
242            .await
243            .unwrap()
244            .unwrap();
245        assert!(result.summary.contains("## Instructions"));
246        assert!(result.summary.contains("Do trusted things"));
247    }
248
249    #[tokio::test]
250    async fn verified_skill_is_sanitized() {
251        let dir = tempfile::tempdir().unwrap();
252        let body = "Normal body <|im_start|>injected";
253        let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
254        let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
255        let executor = make_executor(registry, trust);
256        let result = executor
257            .execute_tool_call(&make_call("verified-skill"))
258            .await
259            .unwrap()
260            .unwrap();
261        assert!(result.summary.contains("Normal body"));
262        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
263        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
264        assert!(
265            !result
266                .summary
267                .replace("[BLOCKED:<|im_start|>]", "")
268                .contains("<|im_start|>")
269        );
270    }
271
272    #[tokio::test]
273    async fn quarantined_skill_is_sanitized_and_wrapped() {
274        let dir = tempfile::tempdir().unwrap();
275        let body = "Quarantined content";
276        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
277        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
278        let executor = make_executor(registry, trust);
279        let result = executor
280            .execute_tool_call(&make_call("quarantined-skill"))
281            .await
282            .unwrap()
283            .unwrap();
284        assert!(result.summary.contains("QUARANTINED"));
285        assert!(result.summary.contains("Quarantined content"));
286    }
287
288    #[tokio::test]
289    async fn blocked_skill_is_refused_without_body_read() {
290        let dir = tempfile::tempdir().unwrap();
291        let body = "secret body that should not be returned";
292        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
293        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
294        let executor = make_executor(registry, trust);
295        let result = executor
296            .execute_tool_call(&make_call("blocked-skill"))
297            .await
298            .unwrap()
299            .unwrap();
300        assert!(result.summary.contains("blocked by policy"));
301        assert!(!result.summary.contains("secret body"));
302    }
303
304    #[tokio::test]
305    async fn no_trust_row_defaults_to_quarantined_behavior() {
306        // Default trust is Quarantined — fail-closed.
307        let dir = tempfile::tempdir().unwrap();
308        let body = "Some body";
309        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
310        let executor = make_executor(registry, HashMap::new());
311        let result = executor
312            .execute_tool_call(&make_call("unknown-skill"))
313            .await
314            .unwrap()
315            .unwrap();
316        // Quarantined path: body is wrapped.
317        assert!(result.summary.contains("QUARANTINED"));
318    }
319
320    #[tokio::test]
321    async fn nonexistent_skill_returns_not_found() {
322        let dir = tempfile::tempdir().unwrap();
323        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
324        let executor = make_executor(registry, HashMap::new());
325        let result = executor
326            .execute_tool_call(&make_call("nonexistent"))
327            .await
328            .unwrap()
329            .unwrap();
330        assert!(result.summary.contains("skill not found"));
331    }
332
333    #[tokio::test]
334    async fn wrong_tool_id_returns_none() {
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("bash"),
340            params: serde_json::Map::new(),
341            caller_id: None,
342            context: None,
343
344            tool_call_id: String::new(),
345        };
346        let result = executor.execute_tool_call(&call).await.unwrap();
347        assert!(result.is_none());
348    }
349
350    #[tokio::test]
351    async fn execute_always_returns_none() {
352        let dir = tempfile::tempdir().unwrap();
353        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
354        let executor = make_executor(registry, HashMap::new());
355        let result = executor.execute("any text").await.unwrap();
356        assert!(result.is_none());
357    }
358
359    #[tokio::test]
360    async fn args_are_appended_to_trusted_body() {
361        let dir = tempfile::tempdir().unwrap();
362        let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
363        let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
364        let executor = make_executor(registry, trust);
365        let result = executor
366            .execute_tool_call(&make_call_with_args("argskill", "user arg"))
367            .await
368            .unwrap()
369            .unwrap();
370        assert!(result.summary.contains("Body text"));
371        assert!(result.summary.contains("<args>"));
372        assert!(result.summary.contains("user arg"));
373    }
374
375    #[tokio::test]
376    async fn args_are_sanitized_regardless_of_trust() {
377        let dir = tempfile::tempdir().unwrap();
378        let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
379        let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
380        let executor = make_executor(registry, trust);
381        let result = executor
382            .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
383            .await
384            .unwrap()
385            .unwrap();
386        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
387        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
388        assert!(
389            !result
390                .summary
391                .replace("[BLOCKED:<|im_start|>]", "")
392                .contains("<|im_start|>")
393        );
394    }
395
396    #[tokio::test]
397    async fn tool_definitions_returns_invoke_skill() {
398        let dir = tempfile::tempdir().unwrap();
399        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
400        let executor = make_executor(registry, HashMap::new());
401        let defs = executor.tool_definitions();
402        assert_eq!(defs.len(), 1);
403        assert_eq!(defs[0].id.as_ref(), "invoke_skill");
404    }
405}