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 }
335 }
336
337 fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
338 ToolCall {
339 tool_id: zeph_common::ToolName::new("invoke_skill"),
340 params: serde_json::json!({"skill_name": skill_name, "args": args})
341 .as_object()
342 .unwrap()
343 .clone(),
344 caller_id: None,
345 context: None,
346
347 tool_call_id: String::new(),
348 }
349 }
350
351 #[tokio::test]
352 async fn trusted_skill_returns_body_verbatim() {
353 let dir = tempfile::tempdir().unwrap();
354 let body = "## Instructions\nDo trusted things";
355 let registry = make_registry_with_skill(dir.path(), "my-skill", body);
356 let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
357 let executor = make_executor(registry, trust);
358 let result = executor
359 .execute_tool_call(&make_call("my-skill"))
360 .await
361 .unwrap()
362 .unwrap();
363 assert!(result.summary.contains("## Instructions"));
364 assert!(result.summary.contains("Do trusted things"));
365 }
366
367 #[tokio::test]
368 async fn verified_skill_is_sanitized() {
369 let dir = tempfile::tempdir().unwrap();
370 let body = "Normal body <|im_start|>injected";
371 let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
372 let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
373 let executor = make_executor(registry, trust);
374 let result = executor
375 .execute_tool_call(&make_call("verified-skill"))
376 .await
377 .unwrap()
378 .unwrap();
379 assert!(result.summary.contains("Normal body"));
380 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
381 assert!(
383 !result
384 .summary
385 .replace("[BLOCKED:<|im_start|>]", "")
386 .contains("<|im_start|>")
387 );
388 }
389
390 #[tokio::test]
391 async fn quarantined_skill_is_sanitized_and_wrapped() {
392 let dir = tempfile::tempdir().unwrap();
393 let body = "Quarantined content";
394 let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
395 let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
396 let executor = make_executor(registry, trust);
397 let result = executor
398 .execute_tool_call(&make_call("quarantined-skill"))
399 .await
400 .unwrap()
401 .unwrap();
402 assert!(result.summary.contains("QUARANTINED"));
403 assert!(result.summary.contains("Quarantined content"));
404 }
405
406 #[tokio::test]
407 async fn blocked_skill_is_refused_without_body_read() {
408 let dir = tempfile::tempdir().unwrap();
409 let body = "secret body that should not be returned";
410 let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
411 let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
412 let executor = make_executor(registry, trust);
413 let result = executor
414 .execute_tool_call(&make_call("blocked-skill"))
415 .await
416 .unwrap()
417 .unwrap();
418 assert!(result.summary.contains("blocked by policy"));
419 assert!(!result.summary.contains("secret body"));
420 }
421
422 #[tokio::test]
423 async fn no_trust_row_defaults_to_quarantined_behavior() {
424 let dir = tempfile::tempdir().unwrap();
426 let body = "Some body";
427 let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
428 let executor = make_executor(registry, HashMap::new());
429 let result = executor
430 .execute_tool_call(&make_call("unknown-skill"))
431 .await
432 .unwrap()
433 .unwrap();
434 assert!(result.summary.contains("QUARANTINED"));
436 }
437
438 #[tokio::test]
439 async fn nonexistent_skill_returns_not_found() {
440 let dir = tempfile::tempdir().unwrap();
441 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
442 let executor = make_executor(registry, HashMap::new());
443 let result = executor
444 .execute_tool_call(&make_call("nonexistent"))
445 .await
446 .unwrap()
447 .unwrap();
448 assert!(result.summary.contains("skill not found"));
449 }
450
451 #[tokio::test]
452 async fn wrong_tool_id_returns_none() {
453 let dir = tempfile::tempdir().unwrap();
454 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
455 let executor = make_executor(registry, HashMap::new());
456 let call = ToolCall {
457 tool_id: zeph_common::ToolName::new("bash"),
458 params: serde_json::Map::new(),
459 caller_id: None,
460 context: None,
461
462 tool_call_id: String::new(),
463 };
464 let result = executor.execute_tool_call(&call).await.unwrap();
465 assert!(result.is_none());
466 }
467
468 #[tokio::test]
469 async fn execute_always_returns_none() {
470 let dir = tempfile::tempdir().unwrap();
471 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
472 let executor = make_executor(registry, HashMap::new());
473 let result = executor.execute("any text").await.unwrap();
474 assert!(result.is_none());
475 }
476
477 #[tokio::test]
478 async fn args_are_appended_to_trusted_body() {
479 let dir = tempfile::tempdir().unwrap();
480 let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
481 let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
482 let executor = make_executor(registry, trust);
483 let result = executor
484 .execute_tool_call(&make_call_with_args("argskill", "user arg"))
485 .await
486 .unwrap()
487 .unwrap();
488 assert!(result.summary.contains("Body text"));
489 assert!(result.summary.contains("<args>"));
490 assert!(result.summary.contains("user arg"));
491 }
492
493 #[tokio::test]
494 async fn args_are_sanitized_regardless_of_trust() {
495 let dir = tempfile::tempdir().unwrap();
496 let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
497 let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
498 let executor = make_executor(registry, trust);
499 let result = executor
500 .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
501 .await
502 .unwrap()
503 .unwrap();
504 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
505 assert!(
507 !result
508 .summary
509 .replace("[BLOCKED:<|im_start|>]", "")
510 .contains("<|im_start|>")
511 );
512 }
513
514 #[tokio::test]
515 async fn tool_definitions_returns_invoke_skill() {
516 let dir = tempfile::tempdir().unwrap();
517 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
518 let executor = make_executor(registry, HashMap::new());
519 let defs = executor.tool_definitions();
520 assert_eq!(defs.len(), 1);
521 assert_eq!(defs[0].id.as_ref(), "invoke_skill");
522 }
523
524 #[tokio::test]
527 async fn hash_match_passes_normally() {
528 let dir = tempfile::tempdir().unwrap();
529 let body = "## Trusted body";
530 let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
531 let skill_dir = dir.path().join("checked-skill");
532 let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
533 let snapshots = HashMap::from([(
534 "checked-skill".to_owned(),
535 SkillTrustSnapshot {
536 trust_level: SkillTrustLevel::Trusted,
537 requires_trust_check: true,
538 blake3_hash: stored_hash,
539 },
540 )]);
541 let executor = make_executor_with_snapshots(registry, snapshots);
542 let result = executor
543 .execute_tool_call(&make_call("checked-skill"))
544 .await
545 .unwrap()
546 .unwrap();
547 assert!(
548 result.summary.contains("Trusted body"),
549 "body returned on hash match"
550 );
551 }
552
553 #[tokio::test]
554 async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
555 let dir = tempfile::tempdir().unwrap();
556 let body = "## Original body";
557 let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
558 let snapshots = HashMap::from([(
559 "tampered-skill".to_owned(),
560 SkillTrustSnapshot {
561 trust_level: SkillTrustLevel::Trusted,
562 requires_trust_check: true,
563 blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
564 .to_owned(),
565 },
566 )]);
567 let snapshot_arc = Arc::new(RwLock::new(snapshots));
568 let executor =
569 SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
570 let result = executor
571 .execute_tool_call(&make_call("tampered-skill"))
572 .await
573 .unwrap()
574 .unwrap();
575 assert!(
576 result.summary.contains("demoted to Quarantined"),
577 "output must mention demotion: {}",
578 result.summary
579 );
580 assert!(
581 !result.summary.contains("Original body"),
582 "body must not be returned on hash mismatch"
583 );
584 let level = snapshot_arc
586 .read()
587 .get("tampered-skill")
588 .map(|s| s.trust_level);
589 assert_eq!(level, Some(SkillTrustLevel::Quarantined));
590 }
591
592 #[tokio::test]
593 async fn requires_trust_check_false_skips_hash() {
594 let dir = tempfile::tempdir().unwrap();
596 let body = "## Body without check";
597 let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
598 let snapshots = HashMap::from([(
599 "no-check-skill".to_owned(),
600 SkillTrustSnapshot {
601 trust_level: SkillTrustLevel::Trusted,
602 requires_trust_check: false,
603 blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
604 },
605 )]);
606 let executor = make_executor_with_snapshots(registry, snapshots);
607 let result = executor
608 .execute_tool_call(&make_call("no-check-skill"))
609 .await
610 .unwrap()
611 .unwrap();
612 assert!(
613 result.summary.contains("Body without check"),
614 "body must be returned when check disabled"
615 );
616 }
617
618 #[tokio::test]
619 async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
620 let dir = tempfile::tempdir().unwrap();
623 let body = "## Some body";
624 let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
625 let snapshots = HashMap::from([(
626 "legacy-skill".to_owned(),
627 SkillTrustSnapshot {
628 trust_level: SkillTrustLevel::Trusted,
629 requires_trust_check: true,
630 blake3_hash: String::new(), },
632 )]);
633 let executor = make_executor_with_snapshots(registry, snapshots);
634 let result = executor
635 .execute_tool_call(&make_call("legacy-skill"))
636 .await
637 .unwrap()
638 .unwrap();
639 assert!(
640 result.summary.contains("no stored hash found"),
641 "must emit distinct error for missing hash: {}",
642 result.summary
643 );
644 assert!(
645 !result.summary.contains("demoted to Quarantined"),
646 "must not emit mismatch message for missing hash: {}",
647 result.summary
648 );
649 assert!(
650 !result.summary.contains("Some body"),
651 "body must not be returned: {}",
652 result.summary
653 );
654 }
655
656 #[tokio::test]
657 async fn skill_dir_none_aborts_invocation() {
658 let dir = tempfile::tempdir().unwrap();
660 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
661 let snapshots = HashMap::from([(
662 "ghost-skill".to_owned(),
663 SkillTrustSnapshot {
664 trust_level: SkillTrustLevel::Trusted,
665 requires_trust_check: true,
666 blake3_hash: "deadbeef".to_owned(),
667 },
668 )]);
669 let executor = make_executor_with_snapshots(registry, snapshots);
670 let result = executor
671 .execute_tool_call(&make_call("ghost-skill"))
672 .await
673 .unwrap()
674 .unwrap();
675 assert!(
677 result.summary.contains("skill directory not found")
678 || result.summary.contains("skill not found"),
679 "must abort when skill_dir is missing: {}",
680 result.summary
681 );
682 }
683}