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