1use std::collections::HashMap;
21use std::sync::Arc;
22
23use parking_lot::RwLock;
24
25use schemars::JsonSchema;
26use serde::Deserialize;
27use zeph_skills::registry::SkillRegistry;
28use zeph_tools::executor::{
29 ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
30};
31use zeph_tools::registry::{InvocationHint, ToolDef};
32
33use crate::skill_invoker::SkillTrustSnapshot;
34use crate::skill_trust_gate::{SkillBodyResolution, SkillTrustGate};
35
36#[derive(Debug, Deserialize, JsonSchema)]
37pub struct LoadSkillParams {
38 pub skill_name: String,
40}
41
42#[derive(Clone, Debug)]
48pub struct SkillLoaderExecutor {
49 gate: SkillTrustGate,
50}
51
52impl SkillLoaderExecutor {
53 #[must_use]
74 pub fn new(
75 registry: Arc<RwLock<SkillRegistry>>,
76 trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
77 ) -> Self {
78 Self {
79 gate: SkillTrustGate::new(registry, trust_snapshot),
80 }
81 }
82}
83
84impl ToolExecutor for SkillLoaderExecutor {
85 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
86 Ok(None)
87 }
88
89 fn tool_definitions(&self) -> Vec<ToolDef> {
90 vec![ToolDef {
91 id: "load_skill".into(),
92 description: "Load the full body of a skill by name when you see a relevant entry in the <other_skills> catalog.\n\nParameters: name (string, required) - exact skill name from the <other_skills> catalog\nReturns: complete skill instructions (SKILL.md body), or error if skill not found\nErrors: InvalidParams if name is empty; Execution if skill not found in registry\nExample: {\"name\": \"code-review\"}".into(),
93 schema: schemars::schema_for!(LoadSkillParams),
94 invocation: InvocationHint::ToolCall,
95 output_schema: None,
96 server_id: None,
97 }]
98 }
99
100 #[tracing::instrument(name = "core.skill_loader.execute", skip_all, fields(skill = tracing::field::Empty))]
101 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
102 if call.tool_id != "load_skill" {
103 return Ok(None);
104 }
105 let params: LoadSkillParams = deserialize_params(&call.params)?;
106 let skill_name: String = params.skill_name.chars().take(128).collect();
107
108 tracing::Span::current().record("skill", skill_name.as_str());
109
110 let summary = match self.gate.resolve_body(&skill_name).await? {
111 SkillBodyResolution::Refused(message) | SkillBodyResolution::NotFound(message) => {
112 message
113 }
114 SkillBodyResolution::Body(wrapped) => truncate_tool_output(&wrapped),
115 };
116
117 Ok(Some(ToolOutput {
118 tool_name: zeph_common::ToolName::new("load_skill"),
119 summary,
120 blocks_executed: 1,
121 filter_stats: None,
122 diff: None,
123 streamed: false,
124 terminal_id: None,
125 locations: None,
126 raw_response: None,
127 claim_source: None,
128 ..Default::default()
129 }))
130 }
131
132 zeph_tools::tool_executor_no_inner_defaults!();
133}
134
135#[cfg(test)]
136mod tests {
137 use std::path::Path;
138
139 use zeph_common::SkillTrustLevel;
140
141 use super::*;
142
143 fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
144 let skill_dir = dir.join(name);
145 std::fs::create_dir_all(&skill_dir).unwrap();
146 std::fs::write(
147 skill_dir.join("SKILL.md"),
148 format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
149 )
150 .unwrap();
151 SkillRegistry::load(&[dir.to_path_buf()])
152 }
153
154 fn make_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
155 SkillTrustSnapshot {
156 trust_level: level,
157 requires_trust_check: false,
158 blake3_hash: String::new(),
159 }
160 }
161
162 fn make_executor(
164 registry: SkillRegistry,
165 trust_map: HashMap<String, SkillTrustLevel>,
166 ) -> SkillLoaderExecutor {
167 let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
168 .into_iter()
169 .map(|(k, v)| (k, make_snapshot(v)))
170 .collect();
171 SkillLoaderExecutor::new(
172 Arc::new(RwLock::new(registry)),
173 Arc::new(RwLock::new(snapshot_map)),
174 )
175 }
176
177 fn make_call(skill_name: &str) -> ToolCall {
178 ToolCall {
179 tool_id: zeph_common::ToolName::new("load_skill"),
180 params: serde_json::json!({"skill_name": skill_name})
181 .as_object()
182 .unwrap()
183 .clone(),
184 caller_id: None,
185 context: None,
186
187 tool_call_id: String::new(),
188 skill_name: None,
189 }
190 }
191
192 #[tokio::test]
193 async fn load_existing_skill_returns_body() {
194 let dir = tempfile::tempdir().unwrap();
195 let registry =
196 make_registry_with_skill(dir.path(), "git-commit", "## Instructions\nDo git stuff");
197 let executor = make_executor(registry, HashMap::new());
198 let result = executor
199 .execute_tool_call(&make_call("git-commit"))
200 .await
201 .unwrap()
202 .unwrap();
203 assert!(result.summary.contains("## Instructions"));
204 assert!(result.summary.contains("Do git stuff"));
205 }
206
207 #[tokio::test]
208 async fn load_nonexistent_skill_returns_error_message() {
209 let dir = tempfile::tempdir().unwrap();
210 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
211 let executor = make_executor(registry, HashMap::new());
212 let result = executor
213 .execute_tool_call(&make_call("nonexistent"))
214 .await
215 .unwrap()
216 .unwrap();
217 assert!(result.summary.contains("skill not found"));
218 assert!(result.summary.contains("nonexistent"));
219 }
220
221 #[test]
222 fn tool_definitions_returns_load_skill() {
223 let dir = tempfile::tempdir().unwrap();
224 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
225 let executor = make_executor(registry, HashMap::new());
226 let defs = executor.tool_definitions();
227 assert_eq!(defs.len(), 1);
228 assert_eq!(defs[0].id.as_ref(), "load_skill");
229 }
230
231 #[tokio::test]
232 async fn execute_returns_none_for_wrong_tool_id() {
233 let dir = tempfile::tempdir().unwrap();
234 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
235 let executor = make_executor(registry, HashMap::new());
236 let call = ToolCall {
237 tool_id: zeph_common::ToolName::new("bash"),
238 params: serde_json::Map::new(),
239 caller_id: None,
240 context: None,
241
242 tool_call_id: String::new(),
243 skill_name: None,
244 };
245 let result = executor.execute_tool_call(&call).await.unwrap();
246 assert!(result.is_none());
247 }
248
249 #[tokio::test]
250 async fn long_skill_body_is_truncated() {
251 use zeph_tools::executor::MAX_TOOL_OUTPUT_CHARS;
252 let dir = tempfile::tempdir().unwrap();
253 let long_body = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
254 let registry = make_registry_with_skill(dir.path(), "big-skill", &long_body);
255 let executor = make_executor(registry, HashMap::new());
256 let result = executor
257 .execute_tool_call(&make_call("big-skill"))
258 .await
259 .unwrap()
260 .unwrap();
261 assert!(result.summary.contains("truncated"));
262 assert!(result.summary.len() < long_body.len() + 200);
263 }
264
265 #[tokio::test]
266 async fn empty_registry_returns_error_message() {
267 let dir = tempfile::tempdir().unwrap();
268 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
269 let executor = make_executor(registry, HashMap::new());
270 let result = executor
271 .execute_tool_call(&make_call("any"))
272 .await
273 .unwrap()
274 .unwrap();
275 assert!(result.summary.contains("skill not found"));
276 }
277
278 #[tokio::test]
280 async fn execute_always_returns_none() {
281 let dir = tempfile::tempdir().unwrap();
282 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
283 let executor = make_executor(registry, HashMap::new());
284 let result = executor.execute("any response text").await.unwrap();
285 assert!(result.is_none());
286 }
287
288 #[tokio::test]
290 async fn concurrent_execute_tool_call_succeeds() {
291 let dir = tempfile::tempdir().unwrap();
292 let registry =
293 make_registry_with_skill(dir.path(), "shared-skill", "## Concurrent test body");
294 let executor = Arc::new(make_executor(registry, HashMap::new()));
295
296 let handles: Vec<_> = (0..8)
297 .map(|_| {
298 let ex = Arc::clone(&executor);
299 tokio::spawn(async move { ex.execute_tool_call(&make_call("shared-skill")).await })
300 })
301 .collect();
302
303 for h in handles {
304 let result = h.await.unwrap().unwrap().unwrap();
305 assert!(result.summary.contains("## Concurrent test body"));
306 }
307 }
308
309 #[tokio::test]
311 async fn empty_skill_name_returns_not_found() {
312 let dir = tempfile::tempdir().unwrap();
313 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
314 let executor = make_executor(registry, HashMap::new());
315 let result = executor
316 .execute_tool_call(&make_call(""))
317 .await
318 .unwrap()
319 .unwrap();
320 assert!(result.summary.contains("skill not found"));
321 }
322
323 #[tokio::test]
325 async fn missing_skill_name_field_returns_error() {
326 let dir = tempfile::tempdir().unwrap();
327 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
328 let executor = make_executor(registry, HashMap::new());
329 let call = ToolCall {
330 tool_id: zeph_common::ToolName::new("load_skill"),
331 params: serde_json::Map::new(),
332 caller_id: None,
333 context: None,
334
335 tool_call_id: String::new(),
336 skill_name: None,
337 };
338 let result = executor.execute_tool_call(&call).await;
339 assert!(result.is_err());
340 }
341
342 #[tokio::test]
345 async fn blocked_skill_is_refused_without_body_read() {
346 let dir = tempfile::tempdir().unwrap();
347 let body = "secret body that should not be returned";
348 let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
349 let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
350 let executor = make_executor(registry, trust);
351 let result = executor
352 .execute_tool_call(&make_call("blocked-skill"))
353 .await
354 .unwrap()
355 .unwrap();
356 assert!(result.summary.contains("blocked by policy"));
357 assert!(!result.summary.contains("secret body"));
358 }
359
360 #[tokio::test]
361 async fn verified_skill_is_sanitized() {
362 let dir = tempfile::tempdir().unwrap();
363 let body = "Normal body <|im_start|>injected";
364 let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
365 let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
366 let executor = make_executor(registry, trust);
367 let result = executor
368 .execute_tool_call(&make_call("verified-skill"))
369 .await
370 .unwrap()
371 .unwrap();
372 assert!(result.summary.contains("Normal body"));
373 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
374 assert!(
375 !result
376 .summary
377 .replace("[BLOCKED:<|im_start|>]", "")
378 .contains("<|im_start|>")
379 );
380 }
381
382 #[tokio::test]
383 async fn quarantined_skill_is_sanitized_and_wrapped() {
384 let dir = tempfile::tempdir().unwrap();
385 let body = "Quarantined content";
386 let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
387 let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
388 let executor = make_executor(registry, trust);
389 let result = executor
390 .execute_tool_call(&make_call("quarantined-skill"))
391 .await
392 .unwrap()
393 .unwrap();
394 assert!(result.summary.contains("QUARANTINED"));
395 assert!(result.summary.contains("Quarantined content"));
396 }
397
398 #[tokio::test]
399 async fn trusted_skill_returns_body_verbatim() {
400 let dir = tempfile::tempdir().unwrap();
401 let body = "## Instructions\nDo trusted things";
402 let registry = make_registry_with_skill(dir.path(), "trusted-skill", body);
403 let trust = HashMap::from([("trusted-skill".to_owned(), SkillTrustLevel::Trusted)]);
404 let executor = make_executor(registry, trust);
405 let result = executor
406 .execute_tool_call(&make_call("trusted-skill"))
407 .await
408 .unwrap()
409 .unwrap();
410 assert!(result.summary.contains("## Instructions"));
411 assert!(result.summary.contains("Do trusted things"));
412 }
413
414 #[tokio::test]
415 async fn no_trust_row_defaults_to_trusted_behavior() {
416 let dir = tempfile::tempdir().unwrap();
421 let body = "Some body";
422 let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
423 let executor = make_executor(registry, HashMap::new());
424 let result = executor
425 .execute_tool_call(&make_call("unknown-skill"))
426 .await
427 .unwrap()
428 .unwrap();
429 assert!(!result.summary.contains("QUARANTINED"));
430 assert!(result.summary.contains(body));
431 }
432
433 #[tokio::test]
434 async fn not_found_error_sanitizes_skill_name() {
435 let dir = tempfile::tempdir().unwrap();
436 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
437 let executor = make_executor(registry, HashMap::new());
438 let result = executor
439 .execute_tool_call(&make_call("<|im_start|>nonexistent"))
440 .await
441 .unwrap()
442 .unwrap();
443 assert!(result.summary.contains("skill not found"));
444 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
445 assert!(
446 !result
447 .summary
448 .replace("[BLOCKED:<|im_start|>]", "")
449 .contains("<|im_start|>")
450 );
451 }
452
453 #[tokio::test]
454 async fn tampered_requires_trust_check_skill_is_caught() {
455 let dir = tempfile::tempdir().unwrap();
456 let body = "## Original body";
457 let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
458 let snapshots = HashMap::from([(
459 "tampered-skill".to_owned(),
460 SkillTrustSnapshot {
461 trust_level: SkillTrustLevel::Trusted,
462 requires_trust_check: true,
463 blake3_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
464 .to_owned(),
465 },
466 )]);
467 let executor = SkillLoaderExecutor::new(
468 Arc::new(RwLock::new(registry)),
469 Arc::new(RwLock::new(snapshots)),
470 );
471 let result = executor
472 .execute_tool_call(&make_call("tampered-skill"))
473 .await
474 .unwrap()
475 .unwrap();
476 assert!(
477 result.summary.contains("demoted to Quarantined"),
478 "output must mention demotion: {}",
479 result.summary
480 );
481 assert!(
482 !result.summary.contains("Original body"),
483 "body must not be returned on hash mismatch: {}",
484 result.summary
485 );
486 }
487}