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