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.get_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 }
214 }
215
216 fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
217 ToolCall {
218 tool_id: zeph_common::ToolName::new("invoke_skill"),
219 params: serde_json::json!({"skill_name": skill_name, "args": args})
220 .as_object()
221 .unwrap()
222 .clone(),
223 caller_id: None,
224 }
225 }
226
227 #[tokio::test]
228 async fn trusted_skill_returns_body_verbatim() {
229 let dir = tempfile::tempdir().unwrap();
230 let body = "## Instructions\nDo trusted things";
231 let registry = make_registry_with_skill(dir.path(), "my-skill", body);
232 let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
233 let executor = make_executor(registry, trust);
234 let result = executor
235 .execute_tool_call(&make_call("my-skill"))
236 .await
237 .unwrap()
238 .unwrap();
239 assert!(result.summary.contains("## Instructions"));
240 assert!(result.summary.contains("Do trusted things"));
241 }
242
243 #[tokio::test]
244 async fn verified_skill_is_sanitized() {
245 let dir = tempfile::tempdir().unwrap();
246 let body = "Normal body <|im_start|>injected";
247 let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
248 let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
249 let executor = make_executor(registry, trust);
250 let result = executor
251 .execute_tool_call(&make_call("verified-skill"))
252 .await
253 .unwrap()
254 .unwrap();
255 assert!(result.summary.contains("Normal body"));
256 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
257 assert!(
259 !result
260 .summary
261 .replace("[BLOCKED:<|im_start|>]", "")
262 .contains("<|im_start|>")
263 );
264 }
265
266 #[tokio::test]
267 async fn quarantined_skill_is_sanitized_and_wrapped() {
268 let dir = tempfile::tempdir().unwrap();
269 let body = "Quarantined content";
270 let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
271 let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
272 let executor = make_executor(registry, trust);
273 let result = executor
274 .execute_tool_call(&make_call("quarantined-skill"))
275 .await
276 .unwrap()
277 .unwrap();
278 assert!(result.summary.contains("QUARANTINED"));
279 assert!(result.summary.contains("Quarantined content"));
280 }
281
282 #[tokio::test]
283 async fn blocked_skill_is_refused_without_body_read() {
284 let dir = tempfile::tempdir().unwrap();
285 let body = "secret body that should not be returned";
286 let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
287 let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
288 let executor = make_executor(registry, trust);
289 let result = executor
290 .execute_tool_call(&make_call("blocked-skill"))
291 .await
292 .unwrap()
293 .unwrap();
294 assert!(result.summary.contains("blocked by policy"));
295 assert!(!result.summary.contains("secret body"));
296 }
297
298 #[tokio::test]
299 async fn no_trust_row_defaults_to_quarantined_behavior() {
300 let dir = tempfile::tempdir().unwrap();
302 let body = "Some body";
303 let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
304 let executor = make_executor(registry, HashMap::new());
305 let result = executor
306 .execute_tool_call(&make_call("unknown-skill"))
307 .await
308 .unwrap()
309 .unwrap();
310 assert!(result.summary.contains("QUARANTINED"));
312 }
313
314 #[tokio::test]
315 async fn nonexistent_skill_returns_not_found() {
316 let dir = tempfile::tempdir().unwrap();
317 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
318 let executor = make_executor(registry, HashMap::new());
319 let result = executor
320 .execute_tool_call(&make_call("nonexistent"))
321 .await
322 .unwrap()
323 .unwrap();
324 assert!(result.summary.contains("skill not found"));
325 }
326
327 #[tokio::test]
328 async fn wrong_tool_id_returns_none() {
329 let dir = tempfile::tempdir().unwrap();
330 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
331 let executor = make_executor(registry, HashMap::new());
332 let call = ToolCall {
333 tool_id: zeph_common::ToolName::new("bash"),
334 params: serde_json::Map::new(),
335 caller_id: None,
336 };
337 let result = executor.execute_tool_call(&call).await.unwrap();
338 assert!(result.is_none());
339 }
340
341 #[tokio::test]
342 async fn execute_always_returns_none() {
343 let dir = tempfile::tempdir().unwrap();
344 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
345 let executor = make_executor(registry, HashMap::new());
346 let result = executor.execute("any text").await.unwrap();
347 assert!(result.is_none());
348 }
349
350 #[tokio::test]
351 async fn args_are_appended_to_trusted_body() {
352 let dir = tempfile::tempdir().unwrap();
353 let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
354 let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
355 let executor = make_executor(registry, trust);
356 let result = executor
357 .execute_tool_call(&make_call_with_args("argskill", "user arg"))
358 .await
359 .unwrap()
360 .unwrap();
361 assert!(result.summary.contains("Body text"));
362 assert!(result.summary.contains("<args>"));
363 assert!(result.summary.contains("user arg"));
364 }
365
366 #[tokio::test]
367 async fn args_are_sanitized_regardless_of_trust() {
368 let dir = tempfile::tempdir().unwrap();
369 let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
370 let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
371 let executor = make_executor(registry, trust);
372 let result = executor
373 .execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
374 .await
375 .unwrap()
376 .unwrap();
377 assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
378 assert!(
380 !result
381 .summary
382 .replace("[BLOCKED:<|im_start|>]", "")
383 .contains("<|im_start|>")
384 );
385 }
386
387 #[tokio::test]
388 async fn tool_definitions_returns_invoke_skill() {
389 let dir = tempfile::tempdir().unwrap();
390 let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
391 let executor = make_executor(registry, HashMap::new());
392 let defs = executor.tool_definitions();
393 assert_eq!(defs.len(), 1);
394 assert_eq!(defs[0].id.as_ref(), "invoke_skill");
395 }
396}