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            server_id: None,
187        }]
188    }
189
190    #[tracing::instrument(name = "core.skill_invoke.execute", skip_all, fields(skill = tracing::field::Empty))]
191    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
192        if call.tool_id != "invoke_skill" {
193            return Ok(None);
194        }
195        let params: InvokeSkillParams = deserialize_params(&call.params)?;
196        let skill_name: String = params.skill_name.chars().take(128).collect();
197
198        tracing::Span::current().record("skill", skill_name.as_str());
199
200        let snapshot = self.resolve_snapshot(&skill_name);
201        let trust = snapshot
202            .as_ref()
203            .map_or(SkillTrustLevel::MISSING_ENTRY_FALLBACK, |s| s.trust_level);
204        // Sanitize skill_name before it appears in any tool output: it originates from the LLM
205        // and could carry injection markers (e.g. `<|im_start|>`).
206        let skill_name_safe = sanitize_skill_text(&skill_name);
207
208        // Blocked skills are refused before any body read — executor defense layer.
209        if trust == SkillTrustLevel::Blocked {
210            return Ok(Some(make_output(format!(
211                "skill is blocked by policy: {skill_name_safe}"
212            ))));
213        }
214
215        // Per-invocation integrity check: re-hash SKILL.md when requires_trust_check is set.
216        if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check) {
217            let abort = self
218                .check_integrity(&skill_name, &skill_name_safe, entry)
219                .await?;
220            if let Some(output) = abort {
221                return Ok(Some(output));
222            }
223        }
224
225        // Clone body out of the read guard before any .await — never hold lock across await.
226        let body = {
227            let guard = self.registry.read();
228            guard.body(&skill_name).map(str::to_owned)
229        };
230
231        let summary = match body {
232            Ok(raw_body) => {
233                // Apply the same pipeline as `format_skills_prompt:194-204`:
234                // sanitize for non-Trusted, additionally wrap for Quarantined.
235                let sanitized = if trust == SkillTrustLevel::Trusted {
236                    raw_body
237                } else {
238                    sanitize_skill_text(&raw_body)
239                };
240                let wrapped = if trust == SkillTrustLevel::Quarantined {
241                    wrap_quarantined(&skill_name_safe, &sanitized)
242                } else {
243                    sanitized
244                };
245                let full = if params.args.trim().is_empty() {
246                    wrapped
247                } else {
248                    let args = params.args.chars().take(4096).collect::<String>();
249                    // args originate from LLM text — sanitize regardless of trust.
250                    let args_safe = sanitize_skill_text(&args);
251                    format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
252                };
253                truncate_tool_output(&full)
254            }
255            Err(_) => format!("skill not found: {skill_name_safe}"),
256        };
257
258        Ok(Some(make_output(summary)))
259    }
260}
261
262fn make_output(summary: String) -> ToolOutput {
263    ToolOutput {
264        tool_name: zeph_common::ToolName::new("invoke_skill"),
265        summary,
266        blocks_executed: 1,
267        filter_stats: None,
268        diff: None,
269        streamed: false,
270        terminal_id: None,
271        locations: None,
272        raw_response: None,
273        claim_source: None,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use std::path::Path;
280
281    use super::*;
282
283    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
284        let skill_dir = dir.join(name);
285        std::fs::create_dir_all(&skill_dir).unwrap();
286        std::fs::write(
287            skill_dir.join("SKILL.md"),
288            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
289        )
290        .unwrap();
291        SkillRegistry::load(&[dir.to_path_buf()])
292    }
293
294    fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
295        SkillTrustSnapshot {
296            trust_level: level,
297            requires_trust_check: false,
298            blake3_hash: String::new(),
299        }
300    }
301
302    fn make_executor(
303        registry: SkillRegistry,
304        trust_map: HashMap<String, SkillTrustLevel>,
305    ) -> SkillInvokeExecutor {
306        let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
307            .into_iter()
308            .map(|(k, v)| (k, make_snapshot(v)))
309            .collect();
310        SkillInvokeExecutor::new(
311            Arc::new(RwLock::new(registry)),
312            Arc::new(RwLock::new(snapshot_map)),
313        )
314    }
315
316    fn make_executor_with_snapshots(
317        registry: SkillRegistry,
318        snapshots: HashMap<String, SkillTrustSnapshot>,
319    ) -> SkillInvokeExecutor {
320        SkillInvokeExecutor::new(
321            Arc::new(RwLock::new(registry)),
322            Arc::new(RwLock::new(snapshots)),
323        )
324    }
325
326    fn make_call(skill_name: &str) -> ToolCall {
327        ToolCall {
328            tool_id: zeph_common::ToolName::new("invoke_skill"),
329            params: serde_json::json!({"skill_name": skill_name})
330                .as_object()
331                .unwrap()
332                .clone(),
333            caller_id: None,
334            context: None,
335
336            tool_call_id: String::new(),
337            skill_name: None,
338        }
339    }
340
341    fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
342        ToolCall {
343            tool_id: zeph_common::ToolName::new("invoke_skill"),
344            params: serde_json::json!({"skill_name": skill_name, "args": args})
345                .as_object()
346                .unwrap()
347                .clone(),
348            caller_id: None,
349            context: None,
350
351            tool_call_id: String::new(),
352            skill_name: None,
353        }
354    }
355
356    #[tokio::test]
357    async fn trusted_skill_returns_body_verbatim() {
358        let dir = tempfile::tempdir().unwrap();
359        let body = "## Instructions\nDo trusted things";
360        let registry = make_registry_with_skill(dir.path(), "my-skill", body);
361        let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
362        let executor = make_executor(registry, trust);
363        let result = executor
364            .execute_tool_call(&make_call("my-skill"))
365            .await
366            .unwrap()
367            .unwrap();
368        assert!(result.summary.contains("## Instructions"));
369        assert!(result.summary.contains("Do trusted things"));
370    }
371
372    #[tokio::test]
373    async fn verified_skill_is_sanitized() {
374        let dir = tempfile::tempdir().unwrap();
375        let body = "Normal body <|im_start|>injected";
376        let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
377        let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
378        let executor = make_executor(registry, trust);
379        let result = executor
380            .execute_tool_call(&make_call("verified-skill"))
381            .await
382            .unwrap()
383            .unwrap();
384        assert!(result.summary.contains("Normal body"));
385        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
386        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
387        assert!(
388            !result
389                .summary
390                .replace("[BLOCKED:<|im_start|>]", "")
391                .contains("<|im_start|>")
392        );
393    }
394
395    #[tokio::test]
396    async fn quarantined_skill_is_sanitized_and_wrapped() {
397        let dir = tempfile::tempdir().unwrap();
398        let body = "Quarantined content";
399        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
400        let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
401        let executor = make_executor(registry, trust);
402        let result = executor
403            .execute_tool_call(&make_call("quarantined-skill"))
404            .await
405            .unwrap()
406            .unwrap();
407        assert!(result.summary.contains("QUARANTINED"));
408        assert!(result.summary.contains("Quarantined content"));
409    }
410
411    #[tokio::test]
412    async fn blocked_skill_is_refused_without_body_read() {
413        let dir = tempfile::tempdir().unwrap();
414        let body = "secret body that should not be returned";
415        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
416        let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
417        let executor = make_executor(registry, trust);
418        let result = executor
419            .execute_tool_call(&make_call("blocked-skill"))
420            .await
421            .unwrap()
422            .unwrap();
423        assert!(result.summary.contains("blocked by policy"));
424        assert!(!result.summary.contains("secret body"));
425    }
426
427    #[tokio::test]
428    async fn no_trust_row_defaults_to_trusted_behavior() {
429        // A missing trust-map entry means "never classified yet", not "known untrusted" —
430        // it must resolve to Trusted (SkillTrustLevel::MISSING_ENTRY_FALLBACK), matching the
431        // documented contract in zeph-common. Falling back to Quarantined here would
432        // spuriously wrap legitimate skills whenever the trust map is transiently empty.
433        let dir = tempfile::tempdir().unwrap();
434        let body = "Some body";
435        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
436        let executor = make_executor(registry, HashMap::new());
437        let result = executor
438            .execute_tool_call(&make_call("unknown-skill"))
439            .await
440            .unwrap()
441            .unwrap();
442        // Trusted path: body is returned unwrapped.
443        assert!(!result.summary.contains("QUARANTINED"));
444        assert!(result.summary.contains(body));
445    }
446
447    #[tokio::test]
448    async fn nonexistent_skill_returns_not_found() {
449        let dir = tempfile::tempdir().unwrap();
450        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
451        let executor = make_executor(registry, HashMap::new());
452        let result = executor
453            .execute_tool_call(&make_call("nonexistent"))
454            .await
455            .unwrap()
456            .unwrap();
457        assert!(result.summary.contains("skill not found"));
458    }
459
460    #[tokio::test]
461    async fn wrong_tool_id_returns_none() {
462        let dir = tempfile::tempdir().unwrap();
463        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
464        let executor = make_executor(registry, HashMap::new());
465        let call = ToolCall {
466            tool_id: zeph_common::ToolName::new("bash"),
467            params: serde_json::Map::new(),
468            caller_id: None,
469            context: None,
470
471            tool_call_id: String::new(),
472            skill_name: None,
473        };
474        let result = executor.execute_tool_call(&call).await.unwrap();
475        assert!(result.is_none());
476    }
477
478    #[tokio::test]
479    async fn execute_always_returns_none() {
480        let dir = tempfile::tempdir().unwrap();
481        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
482        let executor = make_executor(registry, HashMap::new());
483        let result = executor.execute("any text").await.unwrap();
484        assert!(result.is_none());
485    }
486
487    #[tokio::test]
488    async fn args_are_appended_to_trusted_body() {
489        let dir = tempfile::tempdir().unwrap();
490        let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
491        let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
492        let executor = make_executor(registry, trust);
493        let result = executor
494            .execute_tool_call(&make_call_with_args("argskill", "user arg"))
495            .await
496            .unwrap()
497            .unwrap();
498        assert!(result.summary.contains("Body text"));
499        assert!(result.summary.contains("<args>"));
500        assert!(result.summary.contains("user arg"));
501    }
502
503    #[tokio::test]
504    async fn args_are_sanitized_regardless_of_trust() {
505        let dir = tempfile::tempdir().unwrap();
506        let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
507        let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
508        let executor = make_executor(registry, trust);
509        let result = executor
510            .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
511            .await
512            .unwrap()
513            .unwrap();
514        assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
515        // The raw marker must only appear inside the [BLOCKED:...] wrapper, never standalone.
516        assert!(
517            !result
518                .summary
519                .replace("[BLOCKED:<|im_start|>]", "")
520                .contains("<|im_start|>")
521        );
522    }
523
524    #[tokio::test]
525    async fn tool_definitions_returns_invoke_skill() {
526        let dir = tempfile::tempdir().unwrap();
527        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
528        let executor = make_executor(registry, HashMap::new());
529        let defs = executor.tool_definitions();
530        assert_eq!(defs.len(), 1);
531        assert_eq!(defs[0].id.as_ref(), "invoke_skill");
532    }
533
534    // ── Per-invocation trust check tests ────────────────────────────────────
535
536    #[tokio::test]
537    async fn hash_match_passes_normally() {
538        let dir = tempfile::tempdir().unwrap();
539        let body = "## Trusted body";
540        let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
541        let skill_dir = dir.path().join("checked-skill");
542        let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
543        let snapshots = HashMap::from([(
544            "checked-skill".to_owned(),
545            SkillTrustSnapshot {
546                trust_level: SkillTrustLevel::Trusted,
547                requires_trust_check: true,
548                blake3_hash: stored_hash,
549            },
550        )]);
551        let executor = make_executor_with_snapshots(registry, snapshots);
552        let result = executor
553            .execute_tool_call(&make_call("checked-skill"))
554            .await
555            .unwrap()
556            .unwrap();
557        assert!(
558            result.summary.contains("Trusted body"),
559            "body returned on hash match"
560        );
561    }
562
563    #[tokio::test]
564    async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
565        let dir = tempfile::tempdir().unwrap();
566        let body = "## Original body";
567        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
568        let snapshots = HashMap::from([(
569            "tampered-skill".to_owned(),
570            SkillTrustSnapshot {
571                trust_level: SkillTrustLevel::Trusted,
572                requires_trust_check: true,
573                blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
574                    .to_owned(),
575            },
576        )]);
577        let snapshot_arc = Arc::new(RwLock::new(snapshots));
578        let executor =
579            SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
580        let result = executor
581            .execute_tool_call(&make_call("tampered-skill"))
582            .await
583            .unwrap()
584            .unwrap();
585        assert!(
586            result.summary.contains("demoted to Quarantined"),
587            "output must mention demotion: {}",
588            result.summary
589        );
590        assert!(
591            !result.summary.contains("Original body"),
592            "body must not be returned on hash mismatch"
593        );
594        // Snapshot entry must be demoted in memory.
595        let level = snapshot_arc
596            .read()
597            .get("tampered-skill")
598            .map(|s| s.trust_level);
599        assert_eq!(level, Some(SkillTrustLevel::Quarantined));
600    }
601
602    #[tokio::test]
603    async fn requires_trust_check_false_skips_hash() {
604        // When requires_trust_check=false, even a deliberately wrong hash must NOT block.
605        let dir = tempfile::tempdir().unwrap();
606        let body = "## Body without check";
607        let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
608        let snapshots = HashMap::from([(
609            "no-check-skill".to_owned(),
610            SkillTrustSnapshot {
611                trust_level: SkillTrustLevel::Trusted,
612                requires_trust_check: false,
613                blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
614            },
615        )]);
616        let executor = make_executor_with_snapshots(registry, snapshots);
617        let result = executor
618            .execute_tool_call(&make_call("no-check-skill"))
619            .await
620            .unwrap()
621            .unwrap();
622        assert!(
623            result.summary.contains("Body without check"),
624            "body must be returned when check disabled"
625        );
626    }
627
628    #[tokio::test]
629    async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
630        // Legacy DB row or misconfiguration: requires_trust_check=true but blake3_hash is empty.
631        // Must abort with a distinct diagnostic, not "hash mismatch".
632        let dir = tempfile::tempdir().unwrap();
633        let body = "## Some body";
634        let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
635        let snapshots = HashMap::from([(
636            "legacy-skill".to_owned(),
637            SkillTrustSnapshot {
638                trust_level: SkillTrustLevel::Trusted,
639                requires_trust_check: true,
640                blake3_hash: String::new(), // empty — legacy row
641            },
642        )]);
643        let executor = make_executor_with_snapshots(registry, snapshots);
644        let result = executor
645            .execute_tool_call(&make_call("legacy-skill"))
646            .await
647            .unwrap()
648            .unwrap();
649        assert!(
650            result.summary.contains("no stored hash found"),
651            "must emit distinct error for missing hash: {}",
652            result.summary
653        );
654        assert!(
655            !result.summary.contains("demoted to Quarantined"),
656            "must not emit mismatch message for missing hash: {}",
657            result.summary
658        );
659        assert!(
660            !result.summary.contains("Some body"),
661            "body must not be returned: {}",
662            result.summary
663        );
664    }
665
666    #[tokio::test]
667    async fn skill_dir_none_aborts_invocation() {
668        // Skill is in the snapshot with requires_trust_check=true but not in registry.
669        let dir = tempfile::tempdir().unwrap();
670        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
671        let snapshots = HashMap::from([(
672            "ghost-skill".to_owned(),
673            SkillTrustSnapshot {
674                trust_level: SkillTrustLevel::Trusted,
675                requires_trust_check: true,
676                blake3_hash: "deadbeef".to_owned(),
677            },
678        )]);
679        let executor = make_executor_with_snapshots(registry, snapshots);
680        let result = executor
681            .execute_tool_call(&make_call("ghost-skill"))
682            .await
683            .unwrap()
684            .unwrap();
685        // Fail-closed: skill_dir not found → abort.
686        assert!(
687            result.summary.contains("skill directory not found")
688                || result.summary.contains("skill not found"),
689            "must abort when skill_dir is missing: {}",
690            result.summary
691        );
692    }
693}