use std::path::{Path, PathBuf};
use std::sync::Arc;
use futures::future::BoxFuture;
use serde_json::json;
use tokio::sync::RwLock;
use crate::sandbox::{SandboxCommand, SandboxManager};
use crate::skills::minimal_env;
use crate::skills::registry::SkillRegistry;
use echo_core::error::{Result, ToolError};
use echo_core::tools::{Tool, ToolParameters, ToolResult};
const DEFAULT_TIMEOUT_SECS: u64 = 30;
pub struct RunSkillScriptTool {
registry: Arc<RwLock<SkillRegistry>>,
sandbox: Option<Arc<SandboxManager>>,
timeout_secs: u64,
}
impl RunSkillScriptTool {
pub fn new(registry: Arc<RwLock<SkillRegistry>>) -> Self {
Self {
registry,
sandbox: None,
timeout_secs: DEFAULT_TIMEOUT_SECS,
}
}
pub fn with_sandbox_manager(mut self, manager: Arc<SandboxManager>) -> Self {
self.sandbox = Some(manager);
self
}
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
}
impl Tool for RunSkillScriptTool {
fn name(&self) -> &str {
"run_skill_script"
}
fn description(&self) -> &str {
"Execute a script from an activated skill's scripts/ directory. \
The working directory is set to the skill's root. \
Supports Python (.py), Node.js (.js/.ts), Bash (.sh), PowerShell (.ps1), \
Ruby (.rb), Perl (.pl). \
The skill must be activated first via activate_skill."
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "Name of the activated skill"
},
"script": {
"type": "string",
"description": "Relative path to the script (e.g., 'scripts/analyze.py')"
},
"args": {
"type": "string",
"description": "Command-line arguments to pass to the script (optional)",
"default": ""
}
},
"required": ["skill_name", "script"]
})
}
fn execute(&self, parameters: ToolParameters) -> BoxFuture<'_, Result<ToolResult>> {
Box::pin(async move {
let skill_name = parameters
.get("skill_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::MissingParameter("skill_name".to_string()))?
.to_string();
let script_path = parameters
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::MissingParameter("script".to_string()))?
.to_string();
let args_str = parameters
.get("args")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let script_relative_path = Path::new(&script_path);
if script_relative_path.is_absolute()
|| script_relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Ok(ToolResult::error(
"Script path must be a relative path inside the activated skill directory",
));
}
let registry = self.registry.read().await;
if !registry.is_activated(&skill_name) {
return Ok(ToolResult::error(format!(
"Skill '{}' has not been activated. Call activate_skill first.",
skill_name
)));
}
let descriptor = match registry.get_descriptor(&skill_name) {
Some(d) => d,
None => {
return Ok(ToolResult::error(format!(
"Skill '{}' not found in catalog",
skill_name
)));
}
};
if !descriptor.permits_tool(self.name()) {
return Ok(ToolResult::error(format!(
"Skill '{}' does not permit tool '{}'; allowed-tools: {}",
skill_name,
self.name(),
descriptor.allowed_tools.join(", ")
)));
}
let skill_dir = match descriptor.location.parent() {
Some(d) => d.to_path_buf(),
None => {
return Ok(ToolResult::error(format!(
"Cannot determine skill directory for '{}'",
skill_name
)));
}
};
let canonical_skill_dir = match skill_dir.canonicalize() {
Ok(path) => path,
Err(e) => {
return Ok(ToolResult::error(format!(
"Cannot canonicalize skill directory for '{}': {}",
skill_name, e
)));
}
};
let full_script_path = canonical_skill_dir.join(script_relative_path);
let canonical_script_path = match full_script_path.canonicalize() {
Ok(path) => path,
Err(e) => {
return Ok(ToolResult::error(format!(
"Cannot canonicalize script '{}': {}",
script_path, e
)));
}
};
if !canonical_script_path.starts_with(&canonical_skill_dir) {
return Ok(ToolResult::error(
"Resolved script path escapes the skill directory",
));
}
if !canonical_script_path.exists() {
return Ok(ToolResult::error(format!(
"Script not found: {} (in skill '{}')",
script_path, skill_name
)));
}
let invocation = resolve_interpreter(&script_path);
let mut all_args: Vec<String> = invocation.prefix_args.to_vec();
all_args.push(canonical_script_path.display().to_string());
if !args_str.is_empty() {
let parsed =
shlex::split(&args_str).ok_or_else(|| ToolError::InvalidParameter {
name: "args".to_string(),
message: "Malformed script arguments: unmatched quotes or escapes"
.to_string(),
})?;
all_args.extend(parsed);
}
let timeout = std::time::Duration::from_secs(self.timeout_secs);
if let Some(ref manager) = self.sandbox {
let sandbox_cmd = SandboxCommand::program(&invocation.program, all_args)
.with_working_dir(&canonical_skill_dir)
.with_timeout(timeout);
return match manager.execute(sandbox_cmd).await {
Ok(result) => format_execution_result(
&skill_name,
&script_path,
result.exit_code,
&result.stdout,
&result.stderr,
&result.sandbox_type,
),
Err(e) => Ok(ToolResult::error(format!(
"Sandbox execution failed for '{}' in skill '{}': {}",
script_path, skill_name, e
))),
};
}
let mut cmd = tokio::process::Command::new(&invocation.program);
for arg in &all_args {
cmd.arg(arg);
}
cmd.current_dir(&canonical_skill_dir);
cmd.kill_on_drop(true);
let env = minimal_env(
&canonical_skill_dir.display().to_string(),
"", std::collections::HashMap::new(),
);
cmd.env_clear();
for (k, v) in env {
cmd.env(k, v);
}
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
format_execution_result(
&skill_name,
&script_path,
output.status.code().unwrap_or(-1),
&stdout,
&stderr,
"direct",
)
}
Ok(Err(e)) => Ok(ToolResult::error(format!(
"Failed to execute '{}' in skill '{}': {}. \
Ensure the interpreter is installed and on PATH.",
script_path, skill_name, e
))),
Err(_) => Ok(ToolResult::error(format!(
"Script '{}' in skill '{}' timed out after {}s",
script_path, skill_name, self.timeout_secs
))),
}
})
}
}
struct Invocation {
program: String,
prefix_args: Vec<String>,
}
fn resolve_interpreter(script_path: &str) -> Invocation {
let ext = script_path
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"py" => resolve_python(),
"js" | "mjs" | "cjs" => Invocation::simple("node"),
"ts" | "mts" | "tsx" => resolve_typescript(),
"sh" | "bash" => resolve_shell(),
"ps1" => resolve_powershell(),
"rb" => Invocation::simple("ruby"),
"pl" => Invocation::simple("perl"),
"php" => Invocation::simple("php"),
"r" | "R" => Invocation::new("Rscript", vec![]),
_ => resolve_shell(), }
}
impl Invocation {
fn simple(program: &str) -> Self {
Self {
program: program.into(),
prefix_args: vec![],
}
}
fn new(program: &str, prefix_args: Vec<&str>) -> Self {
Self {
program: program.into(),
prefix_args: prefix_args.into_iter().map(String::from).collect(),
}
}
}
fn resolve_python() -> Invocation {
if cfg!(target_os = "windows") {
if which_exists("python") {
Invocation::simple("python")
} else {
Invocation::new("py", vec!["-3"])
}
} else {
if which_exists("python3") {
Invocation::simple("python3")
} else {
Invocation::simple("python")
}
}
}
fn resolve_typescript() -> Invocation {
if which_exists("bun") {
return Invocation::simple("bun");
}
if which_exists("deno") {
return Invocation::new("deno", vec!["run", "--allow-read", "--allow-env"]);
}
Invocation::new("npx", vec!["tsx"])
}
fn resolve_shell() -> Invocation {
if cfg!(target_os = "windows") {
if let Some(git_bash) = find_git_bash() {
return Invocation::simple(git_bash.to_str().unwrap_or("bash"));
}
if which_exists("wsl") {
return Invocation::new("wsl", vec!["bash"]);
}
resolve_powershell()
} else {
Invocation::simple("bash")
}
}
fn resolve_powershell() -> Invocation {
if which_exists("pwsh") {
Invocation::new("pwsh", vec!["-NoProfile", "-NonInteractive", "-File"])
} else if cfg!(target_os = "windows") {
Invocation::new(
"powershell",
vec![
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
],
)
} else {
Invocation::simple("sh")
}
}
#[cfg(target_os = "windows")]
fn find_git_bash() -> Option<PathBuf> {
let candidates = [
std::env::var("ProgramFiles")
.ok()
.map(|p| PathBuf::from(p).join("Git").join("bin").join("bash.exe")),
std::env::var("ProgramFiles(x86)")
.ok()
.map(|p| PathBuf::from(p).join("Git").join("bin").join("bash.exe")),
Some(PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")),
Some(PathBuf::from(r"C:\Program Files (x86)\Git\bin\bash.exe")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate);
}
}
if which_exists("bash") {
return Some(PathBuf::from("bash"));
}
None
}
#[cfg(not(target_os = "windows"))]
fn find_git_bash() -> Option<PathBuf> {
None
}
fn which_exists(cmd: &str) -> bool {
std::process::Command::new(cmd)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn format_execution_result(
skill_name: &str,
script_path: &str,
exit_code: i32,
stdout: &str,
stderr: &str,
sandbox_type: &str,
) -> Result<ToolResult> {
let mut output = format!(
"<script_output skill=\"{}\" script=\"{}\" exit_code=\"{}\" sandbox=\"{}\">\n",
skill_name, script_path, exit_code, sandbox_type,
);
if !stdout.is_empty() {
output.push_str(stdout);
if !stdout.ends_with('\n') {
output.push('\n');
}
}
if !stderr.is_empty() {
output.push_str(&format!("\n<stderr>\n{}</stderr>\n", stderr.trim()));
}
output.push_str("</script_output>");
if exit_code == 0 {
Ok(ToolResult::success(output))
} else {
Ok(ToolResult {
success: false,
output,
error: Some(format!(
"Script '{}' exited with code {}",
script_path, exit_code
)),
bytes: None,
data: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::external::types::SkillDescriptor;
use crate::skills::registry::SkillRegistry;
use std::collections::HashMap;
#[test]
fn test_resolve_python() {
let inv = resolve_python();
assert!(
inv.program == "python3" || inv.program == "python" || inv.program == "py",
"unexpected python program: {}",
inv.program
);
}
#[test]
fn test_resolve_typescript() {
let inv = resolve_typescript();
assert!(
inv.program == "bun" || inv.program == "deno" || inv.program == "npx",
"unexpected ts program: {}",
inv.program
);
}
#[test]
fn test_resolve_shell() {
let inv = resolve_shell();
if cfg!(target_os = "windows") {
assert!(
inv.program.contains("bash")
|| inv.program == "wsl"
|| inv.program == "pwsh"
|| inv.program == "powershell",
"unexpected shell program: {}",
inv.program
);
} else {
assert_eq!(inv.program, "bash");
}
}
#[test]
fn test_resolve_powershell() {
let inv = resolve_powershell();
assert!(
inv.program == "pwsh" || inv.program == "powershell" || inv.program == "sh",
"unexpected ps program: {}",
inv.program
);
}
#[test]
fn test_resolve_interpreter_extensions() {
assert_eq!(resolve_interpreter("test.js").program, "node");
assert_eq!(resolve_interpreter("test.rb").program, "ruby");
assert_eq!(resolve_interpreter("test.pl").program, "perl");
assert_eq!(resolve_interpreter("test.php").program, "php");
assert_eq!(resolve_interpreter("test.R").program, "Rscript");
}
#[test]
fn test_shlex_split_simple() {
let result = shlex::split("--input data.csv --output result.json");
assert_eq!(
result,
Some(vec![
"--input".to_string(),
"data.csv".to_string(),
"--output".to_string(),
"result.json".to_string(),
])
);
}
#[test]
fn test_shlex_split_quotes() {
let result = shlex::split(r#"--name "hello world" --verbose"#);
assert_eq!(
result,
Some(vec![
"--name".to_string(),
"hello world".to_string(),
"--verbose".to_string(),
])
);
}
#[test]
fn test_shlex_split_empty() {
assert!(shlex::split("").unwrap_or_default().is_empty());
assert!(shlex::split(" ").unwrap_or_default().is_empty());
}
#[test]
fn test_shlex_split_malformed() {
assert!(shlex::split(r#"--name "unterminated"#).is_none());
}
#[test]
fn test_invocation_no_longer_has_shell_prefix() {
let inv = Invocation::simple("python3");
assert_eq!(inv.program, "python3");
assert!(inv.prefix_args.is_empty());
}
#[tokio::test]
async fn run_skill_script_enforces_allowed_tools() {
let root =
std::env::temp_dir().join(format!("echo-skill-script-test-{}", std::process::id()));
let skill_dir = root.join("locked-skill");
tokio::fs::create_dir_all(skill_dir.join("scripts"))
.await
.unwrap();
tokio::fs::write(skill_dir.join("SKILL.md"), "body")
.await
.unwrap();
tokio::fs::write(skill_dir.join("scripts/test.py"), "print('hi')\n")
.await
.unwrap();
let mut registry = SkillRegistry::new();
registry.register_descriptor(SkillDescriptor {
name: "locked-skill".into(),
description: "desc".into(),
location: skill_dir.join("SKILL.md"),
license: None,
compatibility: None,
metadata: HashMap::new(),
allowed_tools: vec!["read_skill_resource".into()],
shell: None,
paths: vec![],
hooks: None,
});
registry.mark_activated("locked-skill");
let tool = RunSkillScriptTool::new(Arc::new(RwLock::new(registry)));
let result = tool
.execute(
[
("skill_name".to_string(), json!("locked-skill")),
("script".to_string(), json!("scripts/test.py")),
]
.into(),
)
.await
.unwrap();
assert!(!result.success);
assert!(
result
.error
.unwrap_or_default()
.contains("does not permit tool 'run_skill_script'")
);
let _ = tokio::fs::remove_dir_all(root).await;
}
#[cfg(target_os = "windows")]
#[test]
fn test_find_git_bash_returns_something_or_none() {
let _ = find_git_bash();
}
}