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