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