Skip to main content

deepstrike_sdk/runtime/
sandboxed_skill.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use deepstrike_core::types::skill::SkillMetadata;
5
6/// Execution kind for a skill, determined by file extension.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SkillKind {
9    /// `.md` — injected as context into the LLM (existing behavior).
10    Prompt,
11    /// `.json` — rendered client-side via `{{key}}` template engine (Phase A).
12    ComputeJson,
13    /// `.py` — executed via sandboxed Python subprocess (Phase B).
14    PythonScript,
15}
16
17impl SkillKind {
18    pub fn from_path(path: &Path) -> Option<Self> {
19        match path.extension().and_then(|e| e.to_str()) {
20            Some("md") => Some(Self::Prompt),
21            Some("json") => Some(Self::ComputeJson),
22            Some("py") => Some(Self::PythonScript),
23            _ => None,
24        }
25    }
26}
27
28/// Resolve the filesystem path for a skill by name, trying `.md`, `.json`,
29/// then `.py` in priority order.
30pub fn resolve_skill_path(skill_dir: &Path, name: &str) -> Option<(std::path::PathBuf, SkillKind)> {
31    for (ext, kind) in &[
32        ("md", SkillKind::Prompt),
33        ("json", SkillKind::ComputeJson),
34        ("py", SkillKind::PythonScript),
35    ] {
36        let path = skill_dir.join(format!("{name}.{ext}"));
37        if path.exists() {
38            return Some((path, *kind));
39        }
40    }
41    None
42}
43
44// ── Phase A: JSON pure-compute skills ────────────────────────────────────────
45
46/// Parse metadata from a `.json` skill file.
47///
48/// JSON skill format:
49/// ```json
50/// {
51///   "name": "greet",
52///   "description": "Return a greeting string",
53///   "when_to_use": "greeting, hello",
54///   "template": "Hello, {{name}}! You have {{count}} messages."
55/// }
56/// ```
57pub fn parse_json_skill(path: &Path) -> Option<SkillMetadata> {
58    let content = std::fs::read_to_string(path).ok()?;
59    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
60    let name = v["name"].as_str()?.to_string();
61    let description = v["description"].as_str().unwrap_or("").to_string();
62    let mut meta = SkillMetadata::new(name, description);
63    if let Some(w) = v["when_to_use"].as_str() {
64        meta = meta.with_when_to_use(w);
65    }
66    // P1-B: `allowed_tools` JSON array → declared tool ids for skill gating.
67    if let Some(tools) = v["allowed_tools"].as_array() {
68        meta.allowed_tools = tools
69            .iter()
70            .filter_map(|t| t.as_str())
71            .map(Into::into)
72            .collect();
73    }
74    Some(meta)
75}
76
77/// Execute a JSON skill by rendering its `template` field with caller-supplied args.
78///
79/// Variables are substituted using `{{key}}` syntax; unmatched placeholders are
80/// left as-is so the LLM can see what was missing.
81pub fn execute_json_skill(
82    path: &Path,
83    args: &HashMap<String, serde_json::Value>,
84) -> (String, bool) {
85    let content = match std::fs::read_to_string(path) {
86        Ok(c) => c,
87        Err(e) => return (format!("error: could not read skill file: {e}"), true),
88    };
89    let v: serde_json::Value = match serde_json::from_str(&content) {
90        Ok(v) => v,
91        Err(_) => return ("error: invalid JSON skill format".into(), true),
92    };
93    let template = match v["template"].as_str() {
94        Some(t) => t.to_string(),
95        None => return ("error: JSON skill missing 'template' field".into(), true),
96    };
97    (render_template(&template, args), false)
98}
99
100// ── Phase B: Python script skills ────────────────────────────────────────────
101
102/// Parse metadata from a `.py` skill file.
103///
104/// Metadata is encoded as leading `# key: value` comment lines:
105/// ```python
106/// # name: process_data
107/// # description: Processes structured data and returns a summary
108/// # when_to_use: data processing, analysis, summarization
109/// ```
110pub fn parse_python_skill(path: &Path) -> Option<SkillMetadata> {
111    let content = std::fs::read_to_string(path).ok()?;
112    let name = extract_py_meta(&content, "name")?;
113    let description = extract_py_meta(&content, "description").unwrap_or_default();
114    let mut meta = SkillMetadata::new(name, description);
115    if let Some(w) = extract_py_meta(&content, "when_to_use") {
116        meta = meta.with_when_to_use(w);
117    }
118    // P1-B: `# allowed_tools: a, b` comment → declared tool ids for skill gating.
119    if let Some(t) = extract_py_meta(&content, "allowed_tools") {
120        meta.allowed_tools = t
121            .split(',')
122            .map(|s| s.trim())
123            .filter(|s| !s.is_empty())
124            .map(Into::into)
125            .collect();
126    }
127    Some(meta)
128}
129
130/// Resource limits for Python skill subprocess execution.
131pub struct PythonSkillPolicy {
132    /// Hard timeout for the subprocess. Default: 30 seconds.
133    pub timeout_ms: u64,
134    /// Maximum combined stdout+stderr size before truncation. Default: 64 KiB.
135    pub max_output_bytes: usize,
136}
137
138impl Default for PythonSkillPolicy {
139    fn default() -> Self {
140        Self {
141            timeout_ms: 30_000,
142            max_output_bytes: 65_536,
143        }
144    }
145}
146
147/// Execute a `.py` skill file via a sandboxed Python subprocess.
148///
149/// Each invocation runs in an isolated temporary directory (unique per call)
150/// so concurrent skill calls cannot interfere. Args are JSON-encoded and passed
151/// via the `SKILL_ARGS` environment variable. The combined stdout+stderr is
152/// returned as the skill result.
153///
154/// The subprocess is killed and an error is returned if it exceeds
155/// `policy.timeout_ms`.
156pub async fn execute_python_skill(
157    path: &Path,
158    args: &HashMap<String, serde_json::Value>,
159    sandbox_base: Option<&Path>,
160    policy: &PythonSkillPolicy,
161) -> (String, bool) {
162    let script = match std::fs::read_to_string(path) {
163        Ok(s) => s,
164        Err(e) => return (format!("error: could not read skill file: {e}"), true),
165    };
166    let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".into());
167
168    // Unique workdir per invocation — prevents races between concurrent calls.
169    let base = sandbox_base
170        .map(|p| p.to_path_buf())
171        .unwrap_or_else(|| std::env::temp_dir().join("deepstrike-skills"));
172    let invocation_id = uuid::Uuid::new_v4().to_string();
173    let work_dir = base.join(invocation_id);
174
175    if let Err(e) = tokio::fs::create_dir_all(&work_dir).await {
176        return (format!("error: cannot create sandbox dir: {e}"), true);
177    }
178
179    let mut child = match tokio::process::Command::new("python3")
180        .arg("-c")
181        .arg(&script)
182        .env_clear()
183        .env("SKILL_ARGS", &args_json)
184        .env("HOME", work_dir.to_string_lossy().as_ref())
185        .env("TMPDIR", work_dir.to_string_lossy().as_ref())
186        .env("PATH", "/usr/local/bin:/usr/bin:/bin")
187        .current_dir(&work_dir)
188        .stdin(std::process::Stdio::null())
189        .stdout(std::process::Stdio::piped())
190        .stderr(std::process::Stdio::piped())
191        .spawn()
192    {
193        Ok(c) => c,
194        Err(e) => {
195            let _ = tokio::fs::remove_dir_all(&work_dir).await;
196            return (format!("error: failed to spawn python3: {e}"), true);
197        }
198    };
199
200    // Drain stdout/stderr concurrently before waiting so the child never
201    // blocks on a full pipe buffer (same pattern as ProcessSandboxPlane).
202    use tokio::io::AsyncReadExt;
203    let stdout_pipe = child.stdout.take().expect("stdout was piped");
204    let stderr_pipe = child.stderr.take().expect("stderr was piped");
205    let read_out = tokio::spawn(async move {
206        let mut buf = Vec::new();
207        tokio::io::BufReader::new(stdout_pipe)
208            .read_to_end(&mut buf)
209            .await
210            .ok();
211        buf
212    });
213    let read_err = tokio::spawn(async move {
214        let mut buf = Vec::new();
215        tokio::io::BufReader::new(stderr_pipe)
216            .read_to_end(&mut buf)
217            .await
218            .ok();
219        buf
220    });
221
222    let timeout_dur = tokio::time::Duration::from_millis(policy.timeout_ms);
223    let timed_out = tokio::time::timeout(timeout_dur, child.wait()).await;
224
225    // Best-effort cleanup of the per-invocation workdir.
226    let _ = tokio::fs::remove_dir_all(&work_dir).await;
227
228    let is_error = match timed_out {
229        Ok(Ok(status)) => !status.success(),
230        Ok(Err(e)) => {
231            read_out.abort();
232            read_err.abort();
233            return (format!("error: subprocess IO error: {e}"), true);
234        }
235        Err(_) => {
236            child.kill().await.ok();
237            child.wait().await.ok();
238            read_out.abort();
239            read_err.abort();
240            return (
241                format!("error: skill timed out after {}ms", policy.timeout_ms),
242                true,
243            );
244        }
245    };
246
247    let out_bytes = read_out.await.unwrap_or_default();
248    let err_bytes = read_err.await.unwrap_or_default();
249    let mut combined = [out_bytes, err_bytes].concat();
250    if combined.len() > policy.max_output_bytes {
251        combined.truncate(policy.max_output_bytes);
252        combined.extend_from_slice(b"\n[output truncated]");
253    }
254    let text = String::from_utf8_lossy(&combined).into_owned();
255    (
256        if text.is_empty() {
257            "(no output)".into()
258        } else {
259            text
260        },
261        is_error,
262    )
263}
264
265// ── Template engine ───────────────────────────────────────────────────────────
266
267fn render_template(template: &str, args: &HashMap<String, serde_json::Value>) -> String {
268    let mut out = template.to_string();
269    for (k, v) in args {
270        let placeholder = format!("{{{{{k}}}}}");
271        let val = match v {
272            serde_json::Value::String(s) => s.clone(),
273            other => other.to_string(),
274        };
275        out = out.replace(&placeholder, &val);
276    }
277    out
278}
279
280// ── Python metadata helper ────────────────────────────────────────────────────
281
282fn extract_py_meta(content: &str, key: &str) -> Option<String> {
283    let prefix = format!("# {key}:");
284    content
285        .lines()
286        .find(|l| l.trim_start().starts_with(&prefix))
287        .map(|l| {
288            let pos = l.find(&prefix).unwrap() + prefix.len();
289            l[pos..].trim().to_string()
290        })
291}
292
293// ── Scan helpers ──────────────────────────────────────────────────────────────
294
295/// Scan a skill directory and return metadata for all recognised skill files.
296///
297/// Priority per name: `.md` > `.json` > `.py`. If multiple extensions exist
298/// for the same base name, only the highest-priority one is returned.
299pub fn scan_skill_dir(dir: &Path) -> Vec<SkillMetadata> {
300    let Ok(entries) = std::fs::read_dir(dir) else {
301        return Vec::new();
302    };
303
304    // Collect by base name so we deduplicate if .md and .json both exist.
305    let mut by_name: HashMap<String, (u8, SkillMetadata)> = HashMap::new();
306
307    for entry in entries.flatten() {
308        let path = entry.path();
309        let Some(kind) = SkillKind::from_path(&path) else {
310            continue;
311        };
312        let name = path
313            .file_stem()
314            .and_then(|s| s.to_str())
315            .unwrap_or("")
316            .to_string();
317        if name.is_empty() {
318            continue;
319        }
320
321        let priority = match kind {
322            SkillKind::Prompt => 0u8,
323            SkillKind::ComputeJson => 1,
324            SkillKind::PythonScript => 2,
325        };
326
327        let meta = match kind {
328            SkillKind::Prompt => {
329                if let Ok(content) = std::fs::read_to_string(&path) {
330                    let mut meta = SkillMetadata::new(name.clone(), parse_md_description(&content));
331                    // P1-B: `allowed_tools: a, b` (or `[a, b]`) frontmatter line → declared tool ids.
332                    if let Some(line) = content
333                        .lines()
334                        .find(|l| l.trim_start().starts_with("allowed_tools:"))
335                    {
336                        let raw = line.splitn(2, ':').nth(1).unwrap_or("");
337                        meta.allowed_tools = raw
338                            .trim()
339                            .trim_matches(|c| c == '[' || c == ']')
340                            .split(',')
341                            .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\''))
342                            .filter(|s| !s.is_empty())
343                            .map(Into::into)
344                            .collect();
345                    }
346                    Some(meta)
347                } else {
348                    None
349                }
350            }
351            SkillKind::ComputeJson => parse_json_skill(&path),
352            SkillKind::PythonScript => parse_python_skill(&path),
353        };
354
355        if let Some(meta) = meta {
356            let entry = by_name.entry(name).or_insert((255, meta.clone()));
357            if priority < entry.0 {
358                *entry = (priority, meta);
359            }
360        }
361    }
362
363    by_name.into_values().map(|(_, m)| m).collect()
364}
365
366fn parse_md_description(content: &str) -> String {
367    let body = content.trim_start();
368    if !body.starts_with("---") {
369        return String::new();
370    }
371    let rest = &body[3..];
372    let end = rest.find("\n---").unwrap_or(rest.len());
373    for line in rest[..end].lines() {
374        if let Some(val) = line.strip_prefix("description:") {
375            return val.trim().to_string();
376        }
377    }
378    String::new()
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn skill_kind_from_extension() {
387        assert_eq!(
388            SkillKind::from_path(Path::new("a.md")),
389            Some(SkillKind::Prompt)
390        );
391        assert_eq!(
392            SkillKind::from_path(Path::new("b.json")),
393            Some(SkillKind::ComputeJson)
394        );
395        assert_eq!(
396            SkillKind::from_path(Path::new("c.py")),
397            Some(SkillKind::PythonScript)
398        );
399        assert_eq!(SkillKind::from_path(Path::new("d.ts")), None);
400        assert_eq!(SkillKind::from_path(Path::new("noext")), None);
401    }
402
403    #[test]
404    fn template_substitutes_string_and_numeric() {
405        let mut args = HashMap::new();
406        args.insert("name".into(), serde_json::Value::String("Alice".into()));
407        args.insert("count".into(), serde_json::json!(42));
408        let result = render_template("Hello, {{name}}! You have {{count}} items.", &args);
409        assert_eq!(result, "Hello, Alice! You have 42 items.");
410    }
411
412    #[test]
413    fn template_leaves_unknown_placeholders() {
414        let args = HashMap::new();
415        let result = render_template("Hi {{name}}!", &args);
416        assert_eq!(result, "Hi {{name}}!");
417    }
418
419    #[test]
420    fn extract_py_meta_parses_comment_lines() {
421        let src = "# name: my_skill\n# description: Does stuff\nimport os\n";
422        assert_eq!(extract_py_meta(src, "name").as_deref(), Some("my_skill"));
423        assert_eq!(
424            extract_py_meta(src, "description").as_deref(),
425            Some("Does stuff")
426        );
427        assert_eq!(extract_py_meta(src, "missing"), None);
428    }
429
430    #[test]
431    fn parse_md_description_extracts_frontmatter() {
432        let md = "---\ndescription: A useful skill\nauthor: test\n---\n# Heading\n";
433        assert_eq!(parse_md_description(md), "A useful skill");
434    }
435
436    #[test]
437    fn parse_md_description_empty_without_frontmatter() {
438        assert_eq!(parse_md_description("# No frontmatter"), "");
439    }
440}