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 }]
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 let skill_name_safe = sanitize_skill_text(&skill_name);
204
205 if trust == SkillTrustLevel::Blocked {
207 return Ok(Some(make_output(format!(
208 "skill is blocked by policy: {skill_name_safe}"
209 ))));
210 }
211
212 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 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 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 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 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 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 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 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 #[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 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 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 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(), },
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 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 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}