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