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_tools::executor::{
30 ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
31};
32use zeph_tools::registry::{InvocationHint, ToolDef};
33
34#[derive(Debug, Deserialize, JsonSchema)]
36pub struct InvokeSkillParams {
37 pub skill_name: String,
39 #[serde(default)]
42 pub args: String,
43}
44
45#[derive(Clone, Debug)]
50pub struct SkillInvokeExecutor {
51 registry: Arc<RwLock<SkillRegistry>>,
52 trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustLevel>>>,
56}
57
58impl SkillInvokeExecutor {
59 #[must_use]
64 pub fn new(
65 registry: Arc<RwLock<SkillRegistry>>,
66 trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustLevel>>>,
67 ) -> Self {
68 Self {
69 registry,
70 trust_snapshot,
71 }
72 }
73
74 fn resolve_trust(&self, skill_name: &str) -> SkillTrustLevel {
78 self.trust_snapshot
79 .read()
80 .get(skill_name)
81 .copied()
82 .unwrap_or_default()
83 }
84}
85
86impl ToolExecutor for SkillInvokeExecutor {
87 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
88 Ok(None)
89 }
90
91 fn tool_definitions(&self) -> Vec<ToolDef> {
92 vec![ToolDef {
93 id: "invoke_skill".into(),
94 description: "Invoke a skill by name. Returns the skill body as tool output; the \
95 next turn should act under those instructions. Parameters: \
96 skill_name (required) — exact name from <other_skills>; \
97 args (optional) — <=4096 chars appended as <args>...</args>. \
98 Use when a cataloged skill clearly matches the current task and you \
99 intend to follow it in the next turn."
100 .into(),
101 schema: schemars::schema_for!(InvokeSkillParams),
102 invocation: InvocationHint::ToolCall,
103 output_schema: None,
104 }]
105 }
106
107 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
108 if call.tool_id != "invoke_skill" {
109 return Ok(None);
110 }
111 let params: InvokeSkillParams = deserialize_params(&call.params)?;
112 let skill_name: String = params.skill_name.chars().take(128).collect();
113
114 let trust = self.resolve_trust(&skill_name);
115 let skill_name_safe = sanitize_skill_text(&skill_name);
118
119 if trust == SkillTrustLevel::Blocked {
121 return Ok(Some(make_output(format!(
122 "skill is blocked by policy: {skill_name_safe}"
123 ))));
124 }
125
126 let body = {
128 let guard = self.registry.read();
129 guard.body(&skill_name).map(str::to_owned)
130 };
131
132 let summary = match body {
133 Ok(raw_body) => {
134 let sanitized = if trust == SkillTrustLevel::Trusted {
137 raw_body
138 } else {
139 sanitize_skill_text(&raw_body)
140 };
141 let wrapped = if trust == SkillTrustLevel::Quarantined {
142 wrap_quarantined(&skill_name_safe, &sanitized)
143 } else {
144 sanitized
145 };
146 let full = if params.args.trim().is_empty() {
147 wrapped
148 } else {
149 let args = params.args.chars().take(4096).collect::<String>();
150 let args_safe = sanitize_skill_text(&args);
152 format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
153 };
154 truncate_tool_output(&full)
155 }
156 Err(_) => format!("skill not found: {skill_name_safe}"),
157 };
158
159 Ok(Some(make_output(summary)))
160 }
161}
162
163fn make_output(summary: String) -> ToolOutput {
164 ToolOutput {
165 tool_name: zeph_common::ToolName::new("invoke_skill"),
166 summary,
167 blocks_executed: 1,
168 filter_stats: None,
169 diff: None,
170 streamed: false,
171 terminal_id: None,
172 locations: None,
173 raw_response: None,
174 claim_source: None,
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use std::path::Path;
181
182 use super::*;
183
184 fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
185 let skill_dir = dir.join(name);
186 std::fs::create_dir_all(&skill_dir).unwrap();
187 std::fs::write(
188 skill_dir.join("SKILL.md"),
189 format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
190 )
191 .unwrap();
192 SkillRegistry::load(&[dir.to_path_buf()])
193 }
194
195 fn make_executor(
196 registry: SkillRegistry,
197 trust_map: HashMap<String, SkillTrustLevel>,
198 ) -> SkillInvokeExecutor {
199 SkillInvokeExecutor::new(
200 Arc::new(RwLock::new(registry)),
201 Arc::new(RwLock::new(trust_map)),
202 )
203 }
204
205 fn make_call(skill_name: &str) -> ToolCall {
206 ToolCall {
207 tool_id: zeph_common::ToolName::new("invoke_skill"),
208 params: serde_json::json!({"skill_name": skill_name})
209 .as_object()
210 .unwrap()
211 .clone(),
212 caller_id: None,
213 context: None,
214 }
215 }
216
217 fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
218 ToolCall {
219 tool_id: zeph_common::ToolName::new("invoke_skill"),
220 params: serde_json::json!({"skill_name": skill_name, "args": args})
221 .as_object()
222 .unwrap()
223 .clone(),
224 caller_id: None,
225 context: None,
226 }
227 }
228
229 #[tokio::test]
230 async fn trusted_skill_returns_body_verbatim() {
231 let dir = tempfile::tempdir().unwrap();
232 let body = "## Instructions\nDo trusted things";
233 let registry = make_registry_with_skill(dir.path(), "my-skill", body);
234 let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
235 let executor = make_executor(registry, trust);
236 let result = executor
237 .execute_tool_call(&make_call("my-skill"))
238 .await
239 .unwrap()
240 .unwrap();
241 assert!(result.summary.contains("## Instructions"));
242 assert!(result.summary.contains("Do trusted things"));
243 }
244
245 #[tokio::test]
246 async fn verified_skill_is_sanitized() {
247 let dir = tempfile::tempdir().unwrap();
248 let body = "Normal body <|im_start|>injected";
249 let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
250 let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
251 let executor = make_executor(registry, trust);
252 let result = executor
253 .execute_tool_call(&make_call("verified-skill"))
254 .await
255 .unwrap()
256 .unwrap();
257 assert!(result.summary.contains("Normal body"));
258 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
259 assert!(
261 !result
262 .summary
263 .replace("[BLOCKED:<|im_start|>]", "")
264 .contains("<|im_start|>")
265 );
266 }
267
268 #[tokio::test]
269 async fn quarantined_skill_is_sanitized_and_wrapped() {
270 let dir = tempfile::tempdir().unwrap();
271 let body = "Quarantined content";
272 let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
273 let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
274 let executor = make_executor(registry, trust);
275 let result = executor
276 .execute_tool_call(&make_call("quarantined-skill"))
277 .await
278 .unwrap()
279 .unwrap();
280 assert!(result.summary.contains("QUARANTINED"));
281 assert!(result.summary.contains("Quarantined content"));
282 }
283
284 #[tokio::test]
285 async fn blocked_skill_is_refused_without_body_read() {
286 let dir = tempfile::tempdir().unwrap();
287 let body = "secret body that should not be returned";
288 let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
289 let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
290 let executor = make_executor(registry, trust);
291 let result = executor
292 .execute_tool_call(&make_call("blocked-skill"))
293 .await
294 .unwrap()
295 .unwrap();
296 assert!(result.summary.contains("blocked by policy"));
297 assert!(!result.summary.contains("secret body"));
298 }
299
300 #[tokio::test]
301 async fn no_trust_row_defaults_to_quarantined_behavior() {
302 let dir = tempfile::tempdir().unwrap();
304 let body = "Some body";
305 let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
306 let executor = make_executor(registry, HashMap::new());
307 let result = executor
308 .execute_tool_call(&make_call("unknown-skill"))
309 .await
310 .unwrap()
311 .unwrap();
312 assert!(result.summary.contains("QUARANTINED"));
314 }
315
316 #[tokio::test]
317 async fn nonexistent_skill_returns_not_found() {
318 let dir = tempfile::tempdir().unwrap();
319 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
320 let executor = make_executor(registry, HashMap::new());
321 let result = executor
322 .execute_tool_call(&make_call("nonexistent"))
323 .await
324 .unwrap()
325 .unwrap();
326 assert!(result.summary.contains("skill not found"));
327 }
328
329 #[tokio::test]
330 async fn wrong_tool_id_returns_none() {
331 let dir = tempfile::tempdir().unwrap();
332 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
333 let executor = make_executor(registry, HashMap::new());
334 let call = ToolCall {
335 tool_id: zeph_common::ToolName::new("bash"),
336 params: serde_json::Map::new(),
337 caller_id: None,
338 context: None,
339 };
340 let result = executor.execute_tool_call(&call).await.unwrap();
341 assert!(result.is_none());
342 }
343
344 #[tokio::test]
345 async fn execute_always_returns_none() {
346 let dir = tempfile::tempdir().unwrap();
347 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
348 let executor = make_executor(registry, HashMap::new());
349 let result = executor.execute("any text").await.unwrap();
350 assert!(result.is_none());
351 }
352
353 #[tokio::test]
354 async fn args_are_appended_to_trusted_body() {
355 let dir = tempfile::tempdir().unwrap();
356 let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
357 let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
358 let executor = make_executor(registry, trust);
359 let result = executor
360 .execute_tool_call(&make_call_with_args("argskill", "user arg"))
361 .await
362 .unwrap()
363 .unwrap();
364 assert!(result.summary.contains("Body text"));
365 assert!(result.summary.contains("<args>"));
366 assert!(result.summary.contains("user arg"));
367 }
368
369 #[tokio::test]
370 async fn args_are_sanitized_regardless_of_trust() {
371 let dir = tempfile::tempdir().unwrap();
372 let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
373 let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
374 let executor = make_executor(registry, trust);
375 let result = executor
376 .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
377 .await
378 .unwrap()
379 .unwrap();
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 tool_definitions_returns_invoke_skill() {
392 let dir = tempfile::tempdir().unwrap();
393 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
394 let executor = make_executor(registry, HashMap::new());
395 let defs = executor.tool_definitions();
396 assert_eq!(defs.len(), 1);
397 assert_eq!(defs[0].id.as_ref(), "invoke_skill");
398 }
399}