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_skills::trust::compute_skill_hash;
30use zeph_tools::executor::{
31    ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
32};
33use zeph_tools::registry::{InvocationHint, ToolDef};
34
35/// Per-invocation trust metadata snapshot for a single skill.
36///
37/// Populated once per turn from the trust DB by `build_skill_trust_map` and shared
38/// with `SkillInvokeExecutor` so it can resolve trust without hitting `SQLite` on each
39/// tool call. When `requires_trust_check` is `true`, `execute_tool_call` re-hashes
40/// the skill's `SKILL.md` before dispatch (tamper detection per #4293).
41#[derive(Clone, Debug)]
42pub struct SkillTrustSnapshot {
43    /// Access level governing which tools the skill may invoke.
44    pub trust_level: SkillTrustLevel,
45    /// Whether to re-hash `SKILL.md` on every invocation and abort if the digest changed.
46    pub requires_trust_check: bool,
47    /// blake3 hex hash of `SKILL.md` recorded at trust-grant time.
48    pub blake3_hash: String,
49}
50
51/// Parameters for the `invoke_skill` tool call.
52#[derive(Debug, Deserialize, JsonSchema)]
53pub struct InvokeSkillParams {
54    /// Exact skill name from the `<other_skills>` catalog.
55    pub skill_name: String,
56    /// Optional free-form arguments forwarded verbatim to the skill body as a trailing
57    /// `<args>…</args>` block. Capped at 4096 characters.
58    #[serde(default)]
59    pub args: String,
60}
61
62/// Tool executor that returns a skill body by name with trust-aware sanitization.
63///
64/// Holds a shared reference to the skill registry and a per-turn trust snapshot
65/// refreshed by the agent loop. Both are cheap `Arc` clones — no allocation on hot path.
66#[derive(Clone, Debug)]
67pub struct SkillInvokeExecutor {
68    registry: Arc<RwLock<SkillRegistry>>,
69    /// Per-skill trust snapshot refreshed once per turn by the agent.
70    /// Absence of an entry means no trust row exists — treat as Quarantined
71    /// (see `SkillTrustLevel::default`).
72    trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
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 so updates are
79    /// 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            registry,
87            trust_snapshot,
88        }
89    }
90
91    /// Resolve the trust snapshot entry for a skill.
92    ///
93    /// Returns `None` when no row exists — callers treat absence as Quarantined (fail-closed).
94    fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
95        self.trust_snapshot.read().get(skill_name).cloned()
96    }
97
98    /// Run the per-invocation blake3 integrity check.
99    ///
100    /// Returns `Some(output)` when the invocation must be aborted (hash mismatch, empty stored
101    /// hash, missing skill dir, or IO error). Returns `None` when the check passes and dispatch
102    /// should proceed.
103    async fn check_integrity(
104        &self,
105        skill_name: &str,
106        skill_name_safe: &str,
107        entry: &SkillTrustSnapshot,
108    ) -> Result<Option<ToolOutput>, ToolError> {
109        if entry.blake3_hash.is_empty() {
110            tracing::warn!(
111                skill = %skill_name,
112                "requires_trust_check is set but no stored hash found, aborting invocation"
113            );
114            return Ok(Some(make_output(format!(
115                "skill integrity check failed: {skill_name_safe} \
116                 — requires_trust_check is set but no stored hash found"
117            ))));
118        }
119        let stored_hash = entry.blake3_hash.clone();
120        let skill_dir = {
121            let guard = self.registry.read();
122            guard.skill_dir(skill_name)
123        };
124        let Some(dir) = skill_dir else {
125            tracing::warn!(
126                skill = %skill_name,
127                "requires_trust_check: skill_dir not found, aborting invocation"
128            );
129            return Ok(Some(make_output(format!(
130                "skill integrity check failed: {skill_name_safe} — skill directory not found"
131            ))));
132        };
133        let current_hash = tokio::task::spawn_blocking(move || compute_skill_hash(&dir))
134            .await
135            .map_err(|e| ToolError::InvalidParams {
136                message: format!("spawn_blocking join error: {e}"),
137            })?;
138        match current_hash {
139            Ok(hash) if hash != stored_hash => {
140                tracing::warn!(
141                    skill = %skill_name,
142                    "hash mismatch on per-invocation check, demoting to Quarantined"
143                );
144                self.trust_snapshot
145                    .write()
146                    .entry(skill_name.to_owned())
147                    .and_modify(|e| e.trust_level = SkillTrustLevel::Quarantined);
148                // TODO: persist demotion to trust store (#4293 follow-up)
149                Ok(Some(make_output(format!(
150                    "skill integrity check failed: {skill_name_safe} — demoted to Quarantined"
151                ))))
152            }
153            Err(e) => {
154                tracing::warn!(
155                    skill = %skill_name,
156                    err = %e,
157                    "failed to re-hash skill, aborting invocation"
158                );
159                Ok(Some(make_output(format!(
160                    "skill integrity check failed: {skill_name_safe} — cannot read SKILL.md"
161                ))))
162            }
163            Ok(_) => Ok(None), // hash matches, proceed
164        }
165    }
166}
167
168impl ToolExecutor for SkillInvokeExecutor {
169    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
170        Ok(None)
171    }
172
173    fn tool_definitions(&self) -> Vec<ToolDef> {
174        vec![ToolDef {
175            id: "invoke_skill".into(),
176            description: "Invoke a skill by name. Returns the skill body as tool output; the \
177                next turn should act under those instructions. Parameters: \
178                skill_name (required) — exact name from <other_skills>; \
179                args (optional) — <=4096 chars appended as <args>...</args>. \
180                Use when a cataloged skill clearly matches the current task and you \
181                intend to follow it in the next turn."
182                .into(),
183            schema: schemars::schema_for!(InvokeSkillParams),
184            invocation: InvocationHint::ToolCall,
185            output_schema: None,
186        }]
187    }
188
189    #[tracing::instrument(name = "core.skill_invoke.execute", skip_all, fields(skill = tracing::field::Empty))]
190    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
191        if call.tool_id != "invoke_skill" {
192            return Ok(None);
193        }
194        let params: InvokeSkillParams = deserialize_params(&call.params)?;
195        let skill_name: String = params.skill_name.chars().take(128).collect();
196
197        tracing::Span::current().record("skill", skill_name.as_str());
198
199        let snapshot = self.resolve_snapshot(&skill_name);
200        let trust = snapshot.as_ref().map(|s| s.trust_level).unwrap_or_default();
201        // Sanitize skill_name before it appears in any tool output: it originates from the LLM
202        // and could carry injection markers (e.g. `<|im_start|>`).
203        let skill_name_safe = sanitize_skill_text(&skill_name);
204
205        // Blocked skills are refused before any body read — executor defense layer.
206        if trust == SkillTrustLevel::Blocked {
207            return Ok(Some(make_output(format!(
208                "skill is blocked by policy: {skill_name_safe}"
209            ))));
210        }
211
212        // Per-invocation integrity check: re-hash SKILL.md when requires_trust_check is set.
213        if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check) {
214            let abort = self
215                .check_integrity(&skill_name, &skill_name_safe, entry)
216                .await?;
217            if let Some(output) = abort {
218                return Ok(Some(output));
219            }
220        }
221
222        // Clone body out of the read guard before any .await — never hold lock across await.
223        let body = {
224            let guard = self.registry.read();
225            guard.body(&skill_name).map(str::to_owned)
226        };
227
228        let summary = match body {
229            Ok(raw_body) => {
230                // Apply the same pipeline as `format_skills_prompt:194-204`:
231                // sanitize for non-Trusted, additionally wrap for Quarantined.
232                let sanitized = if trust == SkillTrustLevel::Trusted {
233                    raw_body
234                } else {
235                    sanitize_skill_text(&raw_body)
236                };
237                let wrapped = if trust == SkillTrustLevel::Quarantined {
238                    wrap_quarantined(&skill_name_safe, &sanitized)
239                } else {
240                    sanitized
241                };
242                let full = if params.args.trim().is_empty() {
243                    wrapped
244                } else {
245                    let args = params.args.chars().take(4096).collect::<String>();
246                    // args originate from LLM text — sanitize regardless of trust.
247                    let args_safe = sanitize_skill_text(&args);
248                    format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
249                };
250                truncate_tool_output(&full)
251            }
252            Err(_) => format!("skill not found: {skill_name_safe}"),
253        };
254
255        Ok(Some(make_output(summary)))
256    }
257}
258
259fn make_output(summary: String) -> ToolOutput {
260    ToolOutput {
261        tool_name: zeph_common::ToolName::new("invoke_skill"),
262        summary,
263        blocks_executed: 1,
264        filter_stats: None,
265        diff: None,
266        streamed: false,
267        terminal_id: None,
268        locations: None,
269        raw_response: None,
270        claim_source: None,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use std::path::Path;
277
278    use super::*;
279
280    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
281        let skill_dir = dir.join(name);
282        std::fs::create_dir_all(&skill_dir).unwrap();
283        std::fs::write(
284            skill_dir.join("SKILL.md"),
285            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
286        )
287        .unwrap();
288        SkillRegistry::load(&[dir.to_path_buf()])
289    }
290
291    fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
292        SkillTrustSnapshot {
293            trust_level: level,
294            requires_trust_check: false,
295            blake3_hash: String::new(),
296        }
297    }
298
299    fn make_executor(
300        registry: SkillRegistry,
301        trust_map: HashMap<String, SkillTrustLevel>,
302    ) -> SkillInvokeExecutor {
303        let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
304            .into_iter()
305            .map(|(k, v)| (k, make_snapshot(v)))
306            .collect();
307        SkillInvokeExecutor::new(
308            Arc::new(RwLock::new(registry)),
309            Arc::new(RwLock::new(snapshot_map)),
310        )
311    }
312
313    fn make_executor_with_snapshots(
314        registry: SkillRegistry,
315        snapshots: HashMap<String, SkillTrustSnapshot>,
316    ) -> SkillInvokeExecutor {
317        SkillInvokeExecutor::new(
318            Arc::new(RwLock::new(registry)),
319            Arc::new(RwLock::new(snapshots)),
320        )
321    }
322
323    fn make_call(skill_name: &str) -> ToolCall {
324        ToolCall {
325            tool_id: zeph_common::ToolName::new("invoke_skill"),
326            params: serde_json::json!({"skill_name": skill_name})
327                .as_object()
328                .unwrap()
329                .clone(),
330            caller_id: None,
331            context: None,
332
333            tool_call_id: String::new(),
334            skill_name: None,
335        }
336    }
337
338    fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
339        ToolCall {
340            tool_id: zeph_common::ToolName::new("invoke_skill"),
341            params: serde_json::json!({"skill_name": skill_name, "args": args})
342                .as_object()
343                .unwrap()
344                .clone(),
345            caller_id: None,
346            context: None,
347
348            tool_call_id: String::new(),
349            skill_name: None,
350        }
351    }
352
353    #[tokio::test]
354    async fn trusted_skill_returns_body_verbatim() {
355        let dir = tempfile::tempdir().unwrap();
356        let body = "## Instructions\nDo trusted things";
357        let registry = make_registry_with_skill(dir.path(), "my-skill", body);
358        let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
359        let executor = make_executor(registry, trust);
360        let result = executor
361            .execute_tool_call(&make_call("my-skill"))
362            .await
363            .unwrap()
364            .unwrap();
365        assert!(result.summary.contains("## Instructions"));
366        assert!(result.summary.contains("Do trusted things"));
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        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
384        assert!(
385            !result
386                .summary
387                .replace("[BLOCKED:<|im_start|>]", "")
388                .contains("<|im_start|>")
389        );
390    }
391
392    #[tokio::test]
393    async fn quarantined_skill_is_sanitized_and_wrapped() {
394        let dir = tempfile::tempdir().unwrap();
395        let body = "Quarantined content";
396        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
397        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
398        let executor = make_executor(registry, trust);
399        let result = executor
400            .execute_tool_call(&make_call("quarantined-skill"))
401            .await
402            .unwrap()
403            .unwrap();
404        assert!(result.summary.contains("QUARANTINED"));
405        assert!(result.summary.contains("Quarantined content"));
406    }
407
408    #[tokio::test]
409    async fn blocked_skill_is_refused_without_body_read() {
410        let dir = tempfile::tempdir().unwrap();
411        let body = "secret body that should not be returned";
412        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
413        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
414        let executor = make_executor(registry, trust);
415        let result = executor
416            .execute_tool_call(&make_call("blocked-skill"))
417            .await
418            .unwrap()
419            .unwrap();
420        assert!(result.summary.contains("blocked by policy"));
421        assert!(!result.summary.contains("secret body"));
422    }
423
424    #[tokio::test]
425    async fn no_trust_row_defaults_to_quarantined_behavior() {
426        // Default trust is Quarantined — fail-closed.
427        let dir = tempfile::tempdir().unwrap();
428        let body = "Some body";
429        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
430        let executor = make_executor(registry, HashMap::new());
431        let result = executor
432            .execute_tool_call(&make_call("unknown-skill"))
433            .await
434            .unwrap()
435            .unwrap();
436        // Quarantined path: body is wrapped.
437        assert!(result.summary.contains("QUARANTINED"));
438    }
439
440    #[tokio::test]
441    async fn nonexistent_skill_returns_not_found() {
442        let dir = tempfile::tempdir().unwrap();
443        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
444        let executor = make_executor(registry, HashMap::new());
445        let result = executor
446            .execute_tool_call(&make_call("nonexistent"))
447            .await
448            .unwrap()
449            .unwrap();
450        assert!(result.summary.contains("skill not found"));
451    }
452
453    #[tokio::test]
454    async fn wrong_tool_id_returns_none() {
455        let dir = tempfile::tempdir().unwrap();
456        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
457        let executor = make_executor(registry, HashMap::new());
458        let call = ToolCall {
459            tool_id: zeph_common::ToolName::new("bash"),
460            params: serde_json::Map::new(),
461            caller_id: None,
462            context: None,
463
464            tool_call_id: String::new(),
465            skill_name: None,
466        };
467        let result = executor.execute_tool_call(&call).await.unwrap();
468        assert!(result.is_none());
469    }
470
471    #[tokio::test]
472    async fn execute_always_returns_none() {
473        let dir = tempfile::tempdir().unwrap();
474        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
475        let executor = make_executor(registry, HashMap::new());
476        let result = executor.execute("any text").await.unwrap();
477        assert!(result.is_none());
478    }
479
480    #[tokio::test]
481    async fn args_are_appended_to_trusted_body() {
482        let dir = tempfile::tempdir().unwrap();
483        let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
484        let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
485        let executor = make_executor(registry, trust);
486        let result = executor
487            .execute_tool_call(&make_call_with_args("argskill", "user arg"))
488            .await
489            .unwrap()
490            .unwrap();
491        assert!(result.summary.contains("Body text"));
492        assert!(result.summary.contains("<args>"));
493        assert!(result.summary.contains("user arg"));
494    }
495
496    #[tokio::test]
497    async fn args_are_sanitized_regardless_of_trust() {
498        let dir = tempfile::tempdir().unwrap();
499        let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
500        let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
501        let executor = make_executor(registry, trust);
502        let result = executor
503            .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
504            .await
505            .unwrap()
506            .unwrap();
507        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
508        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
509        assert!(
510            !result
511                .summary
512                .replace("[BLOCKED:<|im_start|>]", "")
513                .contains("<|im_start|>")
514        );
515    }
516
517    #[tokio::test]
518    async fn tool_definitions_returns_invoke_skill() {
519        let dir = tempfile::tempdir().unwrap();
520        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
521        let executor = make_executor(registry, HashMap::new());
522        let defs = executor.tool_definitions();
523        assert_eq!(defs.len(), 1);
524        assert_eq!(defs[0].id.as_ref(), "invoke_skill");
525    }
526
527    // ── Per-invocation trust check tests ────────────────────────────────────
528
529    #[tokio::test]
530    async fn hash_match_passes_normally() {
531        let dir = tempfile::tempdir().unwrap();
532        let body = "## Trusted body";
533        let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
534        let skill_dir = dir.path().join("checked-skill");
535        let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
536        let snapshots = HashMap::from([(
537            "checked-skill".to_owned(),
538            SkillTrustSnapshot {
539                trust_level: SkillTrustLevel::Trusted,
540                requires_trust_check: true,
541                blake3_hash: stored_hash,
542            },
543        )]);
544        let executor = make_executor_with_snapshots(registry, snapshots);
545        let result = executor
546            .execute_tool_call(&make_call("checked-skill"))
547            .await
548            .unwrap()
549            .unwrap();
550        assert!(
551            result.summary.contains("Trusted body"),
552            "body returned on hash match"
553        );
554    }
555
556    #[tokio::test]
557    async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
558        let dir = tempfile::tempdir().unwrap();
559        let body = "## Original body";
560        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
561        let snapshots = HashMap::from([(
562            "tampered-skill".to_owned(),
563            SkillTrustSnapshot {
564                trust_level: SkillTrustLevel::Trusted,
565                requires_trust_check: true,
566                blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
567                    .to_owned(),
568            },
569        )]);
570        let snapshot_arc = Arc::new(RwLock::new(snapshots));
571        let executor =
572            SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
573        let result = executor
574            .execute_tool_call(&make_call("tampered-skill"))
575            .await
576            .unwrap()
577            .unwrap();
578        assert!(
579            result.summary.contains("demoted to Quarantined"),
580            "output must mention demotion: {}",
581            result.summary
582        );
583        assert!(
584            !result.summary.contains("Original body"),
585            "body must not be returned on hash mismatch"
586        );
587        // Snapshot entry must be demoted in memory.
588        let level = snapshot_arc
589            .read()
590            .get("tampered-skill")
591            .map(|s| s.trust_level);
592        assert_eq!(level, Some(SkillTrustLevel::Quarantined));
593    }
594
595    #[tokio::test]
596    async fn requires_trust_check_false_skips_hash() {
597        // When requires_trust_check=false, even a deliberately wrong hash must NOT block.
598        let dir = tempfile::tempdir().unwrap();
599        let body = "## Body without check";
600        let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
601        let snapshots = HashMap::from([(
602            "no-check-skill".to_owned(),
603            SkillTrustSnapshot {
604                trust_level: SkillTrustLevel::Trusted,
605                requires_trust_check: false,
606                blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
607            },
608        )]);
609        let executor = make_executor_with_snapshots(registry, snapshots);
610        let result = executor
611            .execute_tool_call(&make_call("no-check-skill"))
612            .await
613            .unwrap()
614            .unwrap();
615        assert!(
616            result.summary.contains("Body without check"),
617            "body must be returned when check disabled"
618        );
619    }
620
621    #[tokio::test]
622    async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
623        // Legacy DB row or misconfiguration: requires_trust_check=true but blake3_hash is empty.
624        // Must abort with a distinct diagnostic, not "hash mismatch".
625        let dir = tempfile::tempdir().unwrap();
626        let body = "## Some body";
627        let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
628        let snapshots = HashMap::from([(
629            "legacy-skill".to_owned(),
630            SkillTrustSnapshot {
631                trust_level: SkillTrustLevel::Trusted,
632                requires_trust_check: true,
633                blake3_hash: String::new(), // empty — legacy row
634            },
635        )]);
636        let executor = make_executor_with_snapshots(registry, snapshots);
637        let result = executor
638            .execute_tool_call(&make_call("legacy-skill"))
639            .await
640            .unwrap()
641            .unwrap();
642        assert!(
643            result.summary.contains("no stored hash found"),
644            "must emit distinct error for missing hash: {}",
645            result.summary
646        );
647        assert!(
648            !result.summary.contains("demoted to Quarantined"),
649            "must not emit mismatch message for missing hash: {}",
650            result.summary
651        );
652        assert!(
653            !result.summary.contains("Some body"),
654            "body must not be returned: {}",
655            result.summary
656        );
657    }
658
659    #[tokio::test]
660    async fn skill_dir_none_aborts_invocation() {
661        // Skill is in the snapshot with requires_trust_check=true but not in registry.
662        let dir = tempfile::tempdir().unwrap();
663        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
664        let snapshots = HashMap::from([(
665            "ghost-skill".to_owned(),
666            SkillTrustSnapshot {
667                trust_level: SkillTrustLevel::Trusted,
668                requires_trust_check: true,
669                blake3_hash: "deadbeef".to_owned(),
670            },
671        )]);
672        let executor = make_executor_with_snapshots(registry, snapshots);
673        let result = executor
674            .execute_tool_call(&make_call("ghost-skill"))
675            .await
676            .unwrap()
677            .unwrap();
678        // Fail-closed: skill_dir not found → abort.
679        assert!(
680            result.summary.contains("skill directory not found")
681                || result.summary.contains("skill not found"),
682            "must abort when skill_dir is missing: {}",
683            result.summary
684        );
685    }
686}