1use std::collections::HashMap;
23use std::sync::Arc;
24
25use parking_lot::RwLock;
26use schemars::JsonSchema;
27use serde::Deserialize;
28use zeph_common::SkillTrustLevel;
29use zeph_skills::prompt::sanitize_skill_text;
30use zeph_skills::registry::SkillRegistry;
31use zeph_tools::executor::{
32 ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
33};
34use zeph_tools::registry::{InvocationHint, ToolDef};
35
36use crate::skill_trust_gate::{SkillBodyResolution, SkillTrustGate};
37
38#[derive(Clone, Debug)]
45pub struct SkillTrustSnapshot {
46 pub trust_level: SkillTrustLevel,
48 pub requires_trust_check: bool,
50 pub blake3_hash: String,
52}
53
54#[derive(Debug, Deserialize, JsonSchema)]
56pub struct InvokeSkillParams {
57 pub skill_name: String,
59 #[serde(default)]
62 pub args: String,
63}
64
65#[derive(Clone, Debug)]
71pub struct SkillInvokeExecutor {
72 gate: SkillTrustGate,
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 gate: SkillTrustGate::new(registry, trust_snapshot),
87 }
88 }
89}
90
91impl ToolExecutor for SkillInvokeExecutor {
92 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
93 Ok(None)
94 }
95
96 fn tool_definitions(&self) -> Vec<ToolDef> {
97 vec![ToolDef {
98 id: "invoke_skill".into(),
99 description: "Invoke a skill by name. Returns the skill body as tool output; the \
100 next turn should act under those instructions. Parameters: \
101 skill_name (required) — exact name from <other_skills>; \
102 args (optional) — <=4096 chars appended as <args>...</args>. \
103 Use when a cataloged skill clearly matches the current task and you \
104 intend to follow it in the next turn."
105 .into(),
106 schema: schemars::schema_for!(InvokeSkillParams),
107 invocation: InvocationHint::ToolCall,
108 output_schema: None,
109 server_id: None,
110 }]
111 }
112
113 #[tracing::instrument(name = "core.skill_invoke.execute", skip_all, fields(skill = tracing::field::Empty))]
114 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
115 if call.tool_id != "invoke_skill" {
116 return Ok(None);
117 }
118 let params: InvokeSkillParams = deserialize_params(&call.params)?;
119 let skill_name: String = params.skill_name.chars().take(128).collect();
120
121 tracing::Span::current().record("skill", skill_name.as_str());
122
123 let summary = match self.gate.resolve_body(&skill_name).await? {
124 SkillBodyResolution::Refused(message) | SkillBodyResolution::NotFound(message) => {
125 message
126 }
127 SkillBodyResolution::Body(wrapped) => {
128 let full = if params.args.trim().is_empty() {
129 wrapped
130 } else {
131 let args = params.args.chars().take(4096).collect::<String>();
132 let args_safe = sanitize_skill_text(&args);
134 format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
135 };
136 truncate_tool_output(&full)
137 }
138 };
139
140 Ok(Some(make_output(summary)))
141 }
142
143 zeph_tools::tool_executor_no_inner_defaults!();
144}
145
146fn make_output(summary: String) -> ToolOutput {
147 ToolOutput {
148 tool_name: zeph_common::ToolName::new("invoke_skill"),
149 summary,
150 blocks_executed: 1,
151 filter_stats: None,
152 diff: None,
153 streamed: false,
154 terminal_id: None,
155 locations: None,
156 raw_response: None,
157 claim_source: None,
158 ..Default::default()
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use std::path::Path;
165
166 use super::*;
167
168 fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
169 let skill_dir = dir.join(name);
170 std::fs::create_dir_all(&skill_dir).unwrap();
171 std::fs::write(
172 skill_dir.join("SKILL.md"),
173 format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
174 )
175 .unwrap();
176 SkillRegistry::load(&[dir.to_path_buf()])
177 }
178
179 fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
180 SkillTrustSnapshot {
181 trust_level: level,
182 requires_trust_check: false,
183 blake3_hash: String::new(),
184 }
185 }
186
187 fn make_executor(
188 registry: SkillRegistry,
189 trust_map: HashMap<String, SkillTrustLevel>,
190 ) -> SkillInvokeExecutor {
191 let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
192 .into_iter()
193 .map(|(k, v)| (k, make_snapshot(v)))
194 .collect();
195 SkillInvokeExecutor::new(
196 Arc::new(RwLock::new(registry)),
197 Arc::new(RwLock::new(snapshot_map)),
198 )
199 }
200
201 fn make_executor_with_snapshots(
202 registry: SkillRegistry,
203 snapshots: HashMap<String, SkillTrustSnapshot>,
204 ) -> SkillInvokeExecutor {
205 SkillInvokeExecutor::new(
206 Arc::new(RwLock::new(registry)),
207 Arc::new(RwLock::new(snapshots)),
208 )
209 }
210
211 fn make_call(skill_name: &str) -> ToolCall {
212 ToolCall {
213 tool_id: zeph_common::ToolName::new("invoke_skill"),
214 params: serde_json::json!({"skill_name": skill_name})
215 .as_object()
216 .unwrap()
217 .clone(),
218 caller_id: None,
219 context: None,
220
221 tool_call_id: String::new(),
222 skill_name: None,
223 }
224 }
225
226 fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
227 ToolCall {
228 tool_id: zeph_common::ToolName::new("invoke_skill"),
229 params: serde_json::json!({"skill_name": skill_name, "args": args})
230 .as_object()
231 .unwrap()
232 .clone(),
233 caller_id: None,
234 context: None,
235
236 tool_call_id: String::new(),
237 skill_name: None,
238 }
239 }
240
241 #[tokio::test]
242 async fn trusted_skill_returns_body_verbatim() {
243 let dir = tempfile::tempdir().unwrap();
244 let body = "## Instructions\nDo trusted things";
245 let registry = make_registry_with_skill(dir.path(), "my-skill", body);
246 let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
247 let executor = make_executor(registry, trust);
248 let result = executor
249 .execute_tool_call(&make_call("my-skill"))
250 .await
251 .unwrap()
252 .unwrap();
253 assert!(result.summary.contains("## Instructions"));
254 assert!(result.summary.contains("Do trusted things"));
255 }
256
257 #[tokio::test]
258 async fn verified_skill_is_sanitized() {
259 let dir = tempfile::tempdir().unwrap();
260 let body = "Normal body <|im_start|>injected";
261 let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
262 let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
263 let executor = make_executor(registry, trust);
264 let result = executor
265 .execute_tool_call(&make_call("verified-skill"))
266 .await
267 .unwrap()
268 .unwrap();
269 assert!(result.summary.contains("Normal body"));
270 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
271 assert!(
273 !result
274 .summary
275 .replace("[BLOCKED:<|im_start|>]", "")
276 .contains("<|im_start|>")
277 );
278 }
279
280 #[tokio::test]
281 async fn quarantined_skill_is_sanitized_and_wrapped() {
282 let dir = tempfile::tempdir().unwrap();
283 let body = "Quarantined content";
284 let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
285 let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
286 let executor = make_executor(registry, trust);
287 let result = executor
288 .execute_tool_call(&make_call("quarantined-skill"))
289 .await
290 .unwrap()
291 .unwrap();
292 assert!(result.summary.contains("QUARANTINED"));
293 assert!(result.summary.contains("Quarantined content"));
294 }
295
296 #[tokio::test]
297 async fn blocked_skill_is_refused_without_body_read() {
298 let dir = tempfile::tempdir().unwrap();
299 let body = "secret body that should not be returned";
300 let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
301 let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
302 let executor = make_executor(registry, trust);
303 let result = executor
304 .execute_tool_call(&make_call("blocked-skill"))
305 .await
306 .unwrap()
307 .unwrap();
308 assert!(result.summary.contains("blocked by policy"));
309 assert!(!result.summary.contains("secret body"));
310 }
311
312 #[tokio::test]
313 async fn no_trust_row_defaults_to_trusted_behavior() {
314 let dir = tempfile::tempdir().unwrap();
319 let body = "Some body";
320 let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
321 let executor = make_executor(registry, HashMap::new());
322 let result = executor
323 .execute_tool_call(&make_call("unknown-skill"))
324 .await
325 .unwrap()
326 .unwrap();
327 assert!(!result.summary.contains("QUARANTINED"));
329 assert!(result.summary.contains(body));
330 }
331
332 #[tokio::test]
333 async fn nonexistent_skill_returns_not_found() {
334 let dir = tempfile::tempdir().unwrap();
335 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
336 let executor = make_executor(registry, HashMap::new());
337 let result = executor
338 .execute_tool_call(&make_call("nonexistent"))
339 .await
340 .unwrap()
341 .unwrap();
342 assert!(result.summary.contains("skill not found"));
343 }
344
345 #[tokio::test]
346 async fn wrong_tool_id_returns_none() {
347 let dir = tempfile::tempdir().unwrap();
348 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
349 let executor = make_executor(registry, HashMap::new());
350 let call = ToolCall {
351 tool_id: zeph_common::ToolName::new("bash"),
352 params: serde_json::Map::new(),
353 caller_id: None,
354 context: None,
355
356 tool_call_id: String::new(),
357 skill_name: None,
358 };
359 let result = executor.execute_tool_call(&call).await.unwrap();
360 assert!(result.is_none());
361 }
362
363 #[tokio::test]
364 async fn execute_always_returns_none() {
365 let dir = tempfile::tempdir().unwrap();
366 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
367 let executor = make_executor(registry, HashMap::new());
368 let result = executor.execute("any text").await.unwrap();
369 assert!(result.is_none());
370 }
371
372 #[tokio::test]
373 async fn args_are_appended_to_trusted_body() {
374 let dir = tempfile::tempdir().unwrap();
375 let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
376 let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
377 let executor = make_executor(registry, trust);
378 let result = executor
379 .execute_tool_call(&make_call_with_args("argskill", "user arg"))
380 .await
381 .unwrap()
382 .unwrap();
383 assert!(result.summary.contains("Body text"));
384 assert!(result.summary.contains("<args>"));
385 assert!(result.summary.contains("user arg"));
386 }
387
388 #[tokio::test]
389 async fn args_are_sanitized_regardless_of_trust() {
390 let dir = tempfile::tempdir().unwrap();
391 let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
392 let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
393 let executor = make_executor(registry, trust);
394 let result = executor
395 .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
396 .await
397 .unwrap()
398 .unwrap();
399 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
400 assert!(
402 !result
403 .summary
404 .replace("[BLOCKED:<|im_start|>]", "")
405 .contains("<|im_start|>")
406 );
407 }
408
409 #[tokio::test]
410 async fn tool_definitions_returns_invoke_skill() {
411 let dir = tempfile::tempdir().unwrap();
412 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
413 let executor = make_executor(registry, HashMap::new());
414 let defs = executor.tool_definitions();
415 assert_eq!(defs.len(), 1);
416 assert_eq!(defs[0].id.as_ref(), "invoke_skill");
417 }
418
419 #[tokio::test]
422 async fn hash_match_passes_normally() {
423 let dir = tempfile::tempdir().unwrap();
424 let body = "## Trusted body";
425 let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
426 let skill_dir = dir.path().join("checked-skill");
427 let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
428 let snapshots = HashMap::from([(
429 "checked-skill".to_owned(),
430 SkillTrustSnapshot {
431 trust_level: SkillTrustLevel::Trusted,
432 requires_trust_check: true,
433 blake3_hash: stored_hash,
434 },
435 )]);
436 let executor = make_executor_with_snapshots(registry, snapshots);
437 let result = executor
438 .execute_tool_call(&make_call("checked-skill"))
439 .await
440 .unwrap()
441 .unwrap();
442 assert!(
443 result.summary.contains("Trusted body"),
444 "body returned on hash match"
445 );
446 }
447
448 #[tokio::test]
449 async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
450 let dir = tempfile::tempdir().unwrap();
451 let body = "## Original body";
452 let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
453 let snapshots = HashMap::from([(
454 "tampered-skill".to_owned(),
455 SkillTrustSnapshot {
456 trust_level: SkillTrustLevel::Trusted,
457 requires_trust_check: true,
458 blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
459 .to_owned(),
460 },
461 )]);
462 let snapshot_arc = Arc::new(RwLock::new(snapshots));
463 let executor =
464 SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
465 let result = executor
466 .execute_tool_call(&make_call("tampered-skill"))
467 .await
468 .unwrap()
469 .unwrap();
470 assert!(
471 result.summary.contains("demoted to Quarantined"),
472 "output must mention demotion: {}",
473 result.summary
474 );
475 assert!(
476 !result.summary.contains("Original body"),
477 "body must not be returned on hash mismatch"
478 );
479 let level = snapshot_arc
481 .read()
482 .get("tampered-skill")
483 .map(|s| s.trust_level);
484 assert_eq!(level, Some(SkillTrustLevel::Quarantined));
485 }
486
487 #[tokio::test]
488 async fn requires_trust_check_false_skips_hash() {
489 let dir = tempfile::tempdir().unwrap();
491 let body = "## Body without check";
492 let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
493 let snapshots = HashMap::from([(
494 "no-check-skill".to_owned(),
495 SkillTrustSnapshot {
496 trust_level: SkillTrustLevel::Trusted,
497 requires_trust_check: false,
498 blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
499 },
500 )]);
501 let executor = make_executor_with_snapshots(registry, snapshots);
502 let result = executor
503 .execute_tool_call(&make_call("no-check-skill"))
504 .await
505 .unwrap()
506 .unwrap();
507 assert!(
508 result.summary.contains("Body without check"),
509 "body must be returned when check disabled"
510 );
511 }
512
513 #[tokio::test]
514 async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
515 let dir = tempfile::tempdir().unwrap();
518 let body = "## Some body";
519 let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
520 let snapshots = HashMap::from([(
521 "legacy-skill".to_owned(),
522 SkillTrustSnapshot {
523 trust_level: SkillTrustLevel::Trusted,
524 requires_trust_check: true,
525 blake3_hash: String::new(), },
527 )]);
528 let executor = make_executor_with_snapshots(registry, snapshots);
529 let result = executor
530 .execute_tool_call(&make_call("legacy-skill"))
531 .await
532 .unwrap()
533 .unwrap();
534 assert!(
535 result.summary.contains("no stored hash found"),
536 "must emit distinct error for missing hash: {}",
537 result.summary
538 );
539 assert!(
540 !result.summary.contains("demoted to Quarantined"),
541 "must not emit mismatch message for missing hash: {}",
542 result.summary
543 );
544 assert!(
545 !result.summary.contains("Some body"),
546 "body must not be returned: {}",
547 result.summary
548 );
549 }
550
551 #[tokio::test]
552 async fn skill_dir_none_aborts_invocation() {
553 let dir = tempfile::tempdir().unwrap();
555 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
556 let snapshots = HashMap::from([(
557 "ghost-skill".to_owned(),
558 SkillTrustSnapshot {
559 trust_level: SkillTrustLevel::Trusted,
560 requires_trust_check: true,
561 blake3_hash: "deadbeef".to_owned(),
562 },
563 )]);
564 let executor = make_executor_with_snapshots(registry, snapshots);
565 let result = executor
566 .execute_tool_call(&make_call("ghost-skill"))
567 .await
568 .unwrap()
569 .unwrap();
570 assert!(
572 result.summary.contains("skill directory not found")
573 || result.summary.contains("skill not found"),
574 "must abort when skill_dir is missing: {}",
575 result.summary
576 );
577 }
578}