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 shared `SkillTrustGate` (crate-private) applies the same defense-in-depth
12//! pipeline as `format_skills_prompt` (and as `load_skill`'s
13//! [`SkillLoaderExecutor`](crate::skill_loader::SkillLoaderExecutor)):
14//! - Non-Trusted bodies pass through [`sanitize_skill_text`].
15//! - Quarantined bodies are additionally wrapped with [`wrap_quarantined`](zeph_skills::prompt::wrap_quarantined).
16//! - Blocked skills are refused before any body read.
17//! - `args` are always sanitized regardless of trust level (LLM-chosen text).
18//!
19//! `invoke_skill` and `load_skill` are both listed in `QUARANTINE_DENIED`, so when a
20//! Quarantined skill is active the trust gate refuses both before this executor is reached.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24
25use parking_lot::RwLock;
26use schemars::JsonSchema;
27use serde::Deserialize;
28use zeph_common::SkillTrustLevel;
29use zeph_skills::prompt::sanitize_skill_text;
30use zeph_skills::registry::SkillRegistry;
31use zeph_tools::executor::{
32    ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
33};
34use zeph_tools::registry::{InvocationHint, ToolDef};
35
36use crate::skill_trust_gate::{SkillBodyResolution, SkillTrustGate};
37
38/// Per-invocation trust metadata snapshot for a single skill.
39///
40/// Populated once per turn from the trust DB by `build_skill_trust_map` and shared
41/// with `SkillInvokeExecutor` so it can resolve trust without hitting `SQLite` on each
42/// tool call. When `requires_trust_check` is `true`, `execute_tool_call` re-hashes
43/// the skill's `SKILL.md` before dispatch (tamper detection per #4293).
44#[derive(Clone, Debug)]
45pub struct SkillTrustSnapshot {
46    /// Access level governing which tools the skill may invoke.
47    pub trust_level: SkillTrustLevel,
48    /// Whether to re-hash `SKILL.md` on every invocation and abort if the digest changed.
49    pub requires_trust_check: bool,
50    /// blake3 hex hash of `SKILL.md` recorded at trust-grant time.
51    pub blake3_hash: String,
52}
53
54/// Parameters for the `invoke_skill` tool call.
55#[derive(Debug, Deserialize, JsonSchema)]
56pub struct InvokeSkillParams {
57    /// Exact skill name from the `<other_skills>` catalog.
58    pub skill_name: String,
59    /// Optional free-form arguments forwarded verbatim to the skill body as a trailing
60    /// `<args>…</args>` block. Capped at 4096 characters.
61    #[serde(default)]
62    pub args: String,
63}
64
65/// Tool executor that returns a skill body by name with trust-aware sanitization.
66///
67/// Delegates trust resolution, integrity checking, sanitization, and quarantine wrapping to a
68/// shared `SkillTrustGate` (crate-private) — the same pipeline `load_skill`
69/// ([`crate::skill_loader::SkillLoaderExecutor`]) uses, so the two tools cannot drift apart.
70#[derive(Clone, Debug)]
71pub struct SkillInvokeExecutor {
72    gate: SkillTrustGate,
73}
74
75impl SkillInvokeExecutor {
76    /// Create a new executor with shared registry and trust snapshot.
77    ///
78    /// Both `Arc`s must be the same instances held by the agent — and shared with
79    /// `SkillLoaderExecutor` — so updates are visible without re-constructing the executor.
80    #[must_use]
81    pub fn new(
82        registry: Arc<RwLock<SkillRegistry>>,
83        trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
84    ) -> Self {
85        Self {
86            gate: SkillTrustGate::new(registry, trust_snapshot),
87        }
88    }
89}
90
91impl ToolExecutor for SkillInvokeExecutor {
92    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
93        Ok(None)
94    }
95
96    fn tool_definitions(&self) -> Vec<ToolDef> {
97        vec![ToolDef {
98            id: "invoke_skill".into(),
99            description: "Invoke a skill by name. Returns the skill body as tool output; the \
100                next turn should act under those instructions. Parameters: \
101                skill_name (required) — exact name from <other_skills>; \
102                args (optional) — <=4096 chars appended as <args>...</args>. \
103                Use when a cataloged skill clearly matches the current task and you \
104                intend to follow it in the next turn."
105                .into(),
106            schema: schemars::schema_for!(InvokeSkillParams),
107            invocation: InvocationHint::ToolCall,
108            output_schema: None,
109            server_id: None,
110        }]
111    }
112
113    #[tracing::instrument(name = "core.skill_invoke.execute", skip_all, fields(skill = tracing::field::Empty))]
114    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
115        if call.tool_id != "invoke_skill" {
116            return Ok(None);
117        }
118        let params: InvokeSkillParams = deserialize_params(&call.params)?;
119        let skill_name: String = params.skill_name.chars().take(128).collect();
120
121        tracing::Span::current().record("skill", skill_name.as_str());
122
123        let summary = match self.gate.resolve_body(&skill_name).await? {
124            SkillBodyResolution::Refused(message) | SkillBodyResolution::NotFound(message) => {
125                message
126            }
127            SkillBodyResolution::Body(wrapped) => {
128                let full = if params.args.trim().is_empty() {
129                    wrapped
130                } else {
131                    let args = params.args.chars().take(4096).collect::<String>();
132                    // args originate from LLM text — sanitize regardless of trust.
133                    let args_safe = sanitize_skill_text(&args);
134                    format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
135                };
136                truncate_tool_output(&full)
137            }
138        };
139
140        Ok(Some(make_output(summary)))
141    }
142
143    zeph_tools::tool_executor_no_inner_defaults!();
144}
145
146fn make_output(summary: String) -> ToolOutput {
147    ToolOutput {
148        tool_name: zeph_common::ToolName::new("invoke_skill"),
149        summary,
150        blocks_executed: 1,
151        filter_stats: None,
152        diff: None,
153        streamed: false,
154        terminal_id: None,
155        locations: None,
156        raw_response: None,
157        claim_source: None,
158        ..Default::default()
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use std::path::Path;
165
166    use super::*;
167
168    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
169        let skill_dir = dir.join(name);
170        std::fs::create_dir_all(&skill_dir).unwrap();
171        std::fs::write(
172            skill_dir.join("SKILL.md"),
173            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
174        )
175        .unwrap();
176        SkillRegistry::load(&[dir.to_path_buf()])
177    }
178
179    fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
180        SkillTrustSnapshot {
181            trust_level: level,
182            requires_trust_check: false,
183            blake3_hash: String::new(),
184        }
185    }
186
187    fn make_executor(
188        registry: SkillRegistry,
189        trust_map: HashMap<String, SkillTrustLevel>,
190    ) -> SkillInvokeExecutor {
191        let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
192            .into_iter()
193            .map(|(k, v)| (k, make_snapshot(v)))
194            .collect();
195        SkillInvokeExecutor::new(
196            Arc::new(RwLock::new(registry)),
197            Arc::new(RwLock::new(snapshot_map)),
198        )
199    }
200
201    fn make_executor_with_snapshots(
202        registry: SkillRegistry,
203        snapshots: HashMap<String, SkillTrustSnapshot>,
204    ) -> SkillInvokeExecutor {
205        SkillInvokeExecutor::new(
206            Arc::new(RwLock::new(registry)),
207            Arc::new(RwLock::new(snapshots)),
208        )
209    }
210
211    fn make_call(skill_name: &str) -> ToolCall {
212        ToolCall {
213            tool_id: zeph_common::ToolName::new("invoke_skill"),
214            params: serde_json::json!({"skill_name": skill_name})
215                .as_object()
216                .unwrap()
217                .clone(),
218            caller_id: None,
219            context: None,
220
221            tool_call_id: String::new(),
222            skill_name: None,
223        }
224    }
225
226    fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
227        ToolCall {
228            tool_id: zeph_common::ToolName::new("invoke_skill"),
229            params: serde_json::json!({"skill_name": skill_name, "args": args})
230                .as_object()
231                .unwrap()
232                .clone(),
233            caller_id: None,
234            context: None,
235
236            tool_call_id: String::new(),
237            skill_name: None,
238        }
239    }
240
241    #[tokio::test]
242    async fn trusted_skill_returns_body_verbatim() {
243        let dir = tempfile::tempdir().unwrap();
244        let body = "## Instructions\nDo trusted things";
245        let registry = make_registry_with_skill(dir.path(), "my-skill", body);
246        let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
247        let executor = make_executor(registry, trust);
248        let result = executor
249            .execute_tool_call(&make_call("my-skill"))
250            .await
251            .unwrap()
252            .unwrap();
253        assert!(result.summary.contains("## Instructions"));
254        assert!(result.summary.contains("Do trusted things"));
255    }
256
257    #[tokio::test]
258    async fn verified_skill_is_sanitized() {
259        let dir = tempfile::tempdir().unwrap();
260        let body = "Normal body <|im_start|>injected";
261        let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
262        let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
263        let executor = make_executor(registry, trust);
264        let result = executor
265            .execute_tool_call(&make_call("verified-skill"))
266            .await
267            .unwrap()
268            .unwrap();
269        assert!(result.summary.contains("Normal body"));
270        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
271        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
272        assert!(
273            !result
274                .summary
275                .replace("[BLOCKED:<|im_start|>]", "")
276                .contains("<|im_start|>")
277        );
278    }
279
280    #[tokio::test]
281    async fn quarantined_skill_is_sanitized_and_wrapped() {
282        let dir = tempfile::tempdir().unwrap();
283        let body = "Quarantined content";
284        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
285        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
286        let executor = make_executor(registry, trust);
287        let result = executor
288            .execute_tool_call(&make_call("quarantined-skill"))
289            .await
290            .unwrap()
291            .unwrap();
292        assert!(result.summary.contains("QUARANTINED"));
293        assert!(result.summary.contains("Quarantined content"));
294    }
295
296    #[tokio::test]
297    async fn blocked_skill_is_refused_without_body_read() {
298        let dir = tempfile::tempdir().unwrap();
299        let body = "secret body that should not be returned";
300        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
301        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
302        let executor = make_executor(registry, trust);
303        let result = executor
304            .execute_tool_call(&make_call("blocked-skill"))
305            .await
306            .unwrap()
307            .unwrap();
308        assert!(result.summary.contains("blocked by policy"));
309        assert!(!result.summary.contains("secret body"));
310    }
311
312    #[tokio::test]
313    async fn no_trust_row_defaults_to_trusted_behavior() {
314        // A missing trust-map entry means "never classified yet", not "known untrusted" —
315        // it must resolve to Trusted (SkillTrustLevel::MISSING_ENTRY_FALLBACK), matching the
316        // documented contract in zeph-common. Falling back to Quarantined here would
317        // spuriously wrap legitimate skills whenever the trust map is transiently empty.
318        let dir = tempfile::tempdir().unwrap();
319        let body = "Some body";
320        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
321        let executor = make_executor(registry, HashMap::new());
322        let result = executor
323            .execute_tool_call(&make_call("unknown-skill"))
324            .await
325            .unwrap()
326            .unwrap();
327        // Trusted path: body is returned unwrapped.
328        assert!(!result.summary.contains("QUARANTINED"));
329        assert!(result.summary.contains(body));
330    }
331
332    #[tokio::test]
333    async fn nonexistent_skill_returns_not_found() {
334        let dir = tempfile::tempdir().unwrap();
335        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
336        let executor = make_executor(registry, HashMap::new());
337        let result = executor
338            .execute_tool_call(&make_call("nonexistent"))
339            .await
340            .unwrap()
341            .unwrap();
342        assert!(result.summary.contains("skill not found"));
343    }
344
345    #[tokio::test]
346    async fn wrong_tool_id_returns_none() {
347        let dir = tempfile::tempdir().unwrap();
348        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
349        let executor = make_executor(registry, HashMap::new());
350        let call = ToolCall {
351            tool_id: zeph_common::ToolName::new("bash"),
352            params: serde_json::Map::new(),
353            caller_id: None,
354            context: None,
355
356            tool_call_id: String::new(),
357            skill_name: None,
358        };
359        let result = executor.execute_tool_call(&call).await.unwrap();
360        assert!(result.is_none());
361    }
362
363    #[tokio::test]
364    async fn execute_always_returns_none() {
365        let dir = tempfile::tempdir().unwrap();
366        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
367        let executor = make_executor(registry, HashMap::new());
368        let result = executor.execute("any text").await.unwrap();
369        assert!(result.is_none());
370    }
371
372    #[tokio::test]
373    async fn args_are_appended_to_trusted_body() {
374        let dir = tempfile::tempdir().unwrap();
375        let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
376        let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
377        let executor = make_executor(registry, trust);
378        let result = executor
379            .execute_tool_call(&make_call_with_args("argskill", "user arg"))
380            .await
381            .unwrap()
382            .unwrap();
383        assert!(result.summary.contains("Body text"));
384        assert!(result.summary.contains("<args>"));
385        assert!(result.summary.contains("user arg"));
386    }
387
388    #[tokio::test]
389    async fn args_are_sanitized_regardless_of_trust() {
390        let dir = tempfile::tempdir().unwrap();
391        let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
392        let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
393        let executor = make_executor(registry, trust);
394        let result = executor
395            .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
396            .await
397            .unwrap()
398            .unwrap();
399        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
400        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
401        assert!(
402            !result
403                .summary
404                .replace("[BLOCKED:<|im_start|>]", "")
405                .contains("<|im_start|>")
406        );
407    }
408
409    #[tokio::test]
410    async fn tool_definitions_returns_invoke_skill() {
411        let dir = tempfile::tempdir().unwrap();
412        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
413        let executor = make_executor(registry, HashMap::new());
414        let defs = executor.tool_definitions();
415        assert_eq!(defs.len(), 1);
416        assert_eq!(defs[0].id.as_ref(), "invoke_skill");
417    }
418
419    // ── Per-invocation trust check tests ────────────────────────────────────
420
421    #[tokio::test]
422    async fn hash_match_passes_normally() {
423        let dir = tempfile::tempdir().unwrap();
424        let body = "## Trusted body";
425        let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
426        let skill_dir = dir.path().join("checked-skill");
427        let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
428        let snapshots = HashMap::from([(
429            "checked-skill".to_owned(),
430            SkillTrustSnapshot {
431                trust_level: SkillTrustLevel::Trusted,
432                requires_trust_check: true,
433                blake3_hash: stored_hash,
434            },
435        )]);
436        let executor = make_executor_with_snapshots(registry, snapshots);
437        let result = executor
438            .execute_tool_call(&make_call("checked-skill"))
439            .await
440            .unwrap()
441            .unwrap();
442        assert!(
443            result.summary.contains("Trusted body"),
444            "body returned on hash match"
445        );
446    }
447
448    #[tokio::test]
449    async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
450        let dir = tempfile::tempdir().unwrap();
451        let body = "## Original body";
452        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
453        let snapshots = HashMap::from([(
454            "tampered-skill".to_owned(),
455            SkillTrustSnapshot {
456                trust_level: SkillTrustLevel::Trusted,
457                requires_trust_check: true,
458                blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
459                    .to_owned(),
460            },
461        )]);
462        let snapshot_arc = Arc::new(RwLock::new(snapshots));
463        let executor =
464            SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
465        let result = executor
466            .execute_tool_call(&make_call("tampered-skill"))
467            .await
468            .unwrap()
469            .unwrap();
470        assert!(
471            result.summary.contains("demoted to Quarantined"),
472            "output must mention demotion: {}",
473            result.summary
474        );
475        assert!(
476            !result.summary.contains("Original body"),
477            "body must not be returned on hash mismatch"
478        );
479        // Snapshot entry must be demoted in memory.
480        let level = snapshot_arc
481            .read()
482            .get("tampered-skill")
483            .map(|s| s.trust_level);
484        assert_eq!(level, Some(SkillTrustLevel::Quarantined));
485    }
486
487    #[tokio::test]
488    async fn requires_trust_check_false_skips_hash() {
489        // When requires_trust_check=false, even a deliberately wrong hash must NOT block.
490        let dir = tempfile::tempdir().unwrap();
491        let body = "## Body without check";
492        let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
493        let snapshots = HashMap::from([(
494            "no-check-skill".to_owned(),
495            SkillTrustSnapshot {
496                trust_level: SkillTrustLevel::Trusted,
497                requires_trust_check: false,
498                blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
499            },
500        )]);
501        let executor = make_executor_with_snapshots(registry, snapshots);
502        let result = executor
503            .execute_tool_call(&make_call("no-check-skill"))
504            .await
505            .unwrap()
506            .unwrap();
507        assert!(
508            result.summary.contains("Body without check"),
509            "body must be returned when check disabled"
510        );
511    }
512
513    #[tokio::test]
514    async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
515        // Legacy DB row or misconfiguration: requires_trust_check=true but blake3_hash is empty.
516        // Must abort with a distinct diagnostic, not "hash mismatch".
517        let dir = tempfile::tempdir().unwrap();
518        let body = "## Some body";
519        let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
520        let snapshots = HashMap::from([(
521            "legacy-skill".to_owned(),
522            SkillTrustSnapshot {
523                trust_level: SkillTrustLevel::Trusted,
524                requires_trust_check: true,
525                blake3_hash: String::new(), // empty — legacy row
526            },
527        )]);
528        let executor = make_executor_with_snapshots(registry, snapshots);
529        let result = executor
530            .execute_tool_call(&make_call("legacy-skill"))
531            .await
532            .unwrap()
533            .unwrap();
534        assert!(
535            result.summary.contains("no stored hash found"),
536            "must emit distinct error for missing hash: {}",
537            result.summary
538        );
539        assert!(
540            !result.summary.contains("demoted to Quarantined"),
541            "must not emit mismatch message for missing hash: {}",
542            result.summary
543        );
544        assert!(
545            !result.summary.contains("Some body"),
546            "body must not be returned: {}",
547            result.summary
548        );
549    }
550
551    #[tokio::test]
552    async fn skill_dir_none_aborts_invocation() {
553        // Skill is in the snapshot with requires_trust_check=true but not in registry.
554        let dir = tempfile::tempdir().unwrap();
555        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
556        let snapshots = HashMap::from([(
557            "ghost-skill".to_owned(),
558            SkillTrustSnapshot {
559                trust_level: SkillTrustLevel::Trusted,
560                requires_trust_check: true,
561                blake3_hash: "deadbeef".to_owned(),
562            },
563        )]);
564        let executor = make_executor_with_snapshots(registry, snapshots);
565        let result = executor
566            .execute_tool_call(&make_call("ghost-skill"))
567            .await
568            .unwrap()
569            .unwrap();
570        // Fail-closed: skill_dir not found → abort.
571        assert!(
572            result.summary.contains("skill directory not found")
573                || result.summary.contains("skill not found"),
574            "must abort when skill_dir is missing: {}",
575            result.summary
576        );
577    }
578}