1use 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#[derive(Clone, Debug)]
42pub struct SkillTrustSnapshot {
43 pub trust_level: SkillTrustLevel,
45 pub requires_trust_check: bool,
47 pub blake3_hash: String,
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
53pub struct InvokeSkillParams {
54 pub skill_name: String,
56 #[serde(default)]
59 pub args: String,
60}
61
62#[derive(Clone, Debug)]
67pub struct SkillInvokeExecutor {
68 registry: Arc<RwLock<SkillRegistry>>,
69 trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
73}
74
75impl SkillInvokeExecutor {
76 #[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 fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
95 self.trust_snapshot.read().get(skill_name).cloned()
96 }
97
98 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 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), }
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 let skill_name_safe = sanitize_skill_text(&skill_name);
207
208 if trust == SkillTrustLevel::Blocked {
210 return Ok(Some(make_output(format!(
211 "skill is blocked by policy: {skill_name_safe}"
212 ))));
213 }
214
215 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 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 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 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 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 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 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 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 #[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 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 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 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(), },
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 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 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}