use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use super::usage_stats;
use crate::high_risk;
use crate::types::{EventSink, ToolResult};
use skilllite_core::skill::metadata::{self, SkillMetadata};
use skilllite_sandbox::runner::{ResourceLimits, SandboxConfig, SandboxLevel};
use super::loader::sanitize_tool_name;
use super::security::{compute_skill_hash, run_security_scan};
use super::LoadedSkill;
pub fn execute_skill(
skill: &LoadedSkill,
tool_name: &str,
arguments: &str,
workspace: &Path,
event_sink: &mut dyn EventSink,
entry_point_override: Option<&str>,
) -> ToolResult {
let result = execute_skill_inner(
skill,
tool_name,
arguments,
workspace,
event_sink,
entry_point_override,
);
match result {
Ok(content) => {
usage_stats::track_skill_execution(&skill.name, true);
ToolResult {
tool_call_id: String::new(),
tool_name: tool_name.to_string(),
content,
is_error: false,
counts_as_failure: false,
}
}
Err(e) => {
usage_stats::track_skill_execution(&skill.name, false);
ToolResult {
tool_call_id: String::new(),
tool_name: tool_name.to_string(),
content: format!("Error: {}", e),
is_error: true,
counts_as_failure: true,
}
}
}
}
thread_local! {
static CONFIRMED_SKILLS: std::cell::RefCell<HashMap<String, String>> =
std::cell::RefCell::new(HashMap::new());
}
fn execute_skill_inner(
skill: &LoadedSkill,
tool_name: &str,
arguments: &str,
workspace: &Path,
event_sink: &mut dyn EventSink,
entry_point_override: Option<&str>,
) -> Result<String> {
let skill_dir = &skill.skill_dir;
let mut metadata = skill.metadata.clone();
if let Some(msg) = skilllite_core::skill::denylist::deny_reason_for_skill_name(&metadata.name) {
anyhow::bail!(msg);
}
if metadata.entry_point.is_empty() {
if let Some(ep) = entry_point_override {
if !ep.is_empty() && skill_dir.join(ep).is_file() {
metadata.entry_point = ep.to_string();
}
}
}
let multi_script_entry: Option<&String> =
skill.multi_script_entries.get(tool_name).or_else(|| {
skill
.multi_script_entries
.get(&sanitize_tool_name(tool_name))
});
let sandbox_level = SandboxLevel::from_env_or_cli(None);
if sandbox_level == SandboxLevel::Level3 {
let code_hash = compute_skill_hash(skill_dir, &metadata);
let already_confirmed = CONFIRMED_SKILLS.with(|cache| {
let cache = cache.borrow();
cache.get(&skill.name) == Some(&code_hash)
});
if !already_confirmed {
let scan_report = run_security_scan(skill_dir, &metadata);
let prompt = if let Some(report) = scan_report {
format!(
"Skill '{}' security scan results:\n\n{}\n\nAllow execution?",
skill.name, report
)
} else {
format!(
"Skill '{}' wants to execute code (sandbox level 3). Allow?",
skill.name
)
};
if !event_sink.on_confirmation_request(&prompt) {
return Ok("Execution cancelled by user.".to_string());
}
CONFIRMED_SKILLS.with(|cache| {
cache.borrow_mut().insert(skill.name.clone(), code_hash);
});
}
}
if high_risk::confirm_network() && metadata.network.enabled {
let network_cache_key = format!("{}:network", skill.name);
let already_network_confirmed = CONFIRMED_SKILLS.with(|cache| {
let cache = cache.borrow();
cache.get(&network_cache_key).is_some()
});
if !already_network_confirmed {
let msg = format!(
"⚠️ 网络访问确认\n\nSkill '{}' 将发起网络请求。\n\n确认执行?",
skill.name
);
if !event_sink.on_confirmation_request(&msg) {
return Ok("Execution cancelled: network skill not confirmed by user.".to_string());
}
CONFIRMED_SKILLS.with(|cache| {
cache
.borrow_mut()
.insert(network_cache_key, "confirmed".to_string());
});
}
}
let cache_dir = skilllite_core::config::CacheConfig::cache_dir();
let env_spec = skilllite_core::EnvSpec::from_metadata(skill_dir, &metadata);
let env_path = skilllite_sandbox::env::builder::ensure_environment(
skill_dir,
&env_spec,
cache_dir.as_deref(),
None,
None,
)?;
let limits = ResourceLimits::from_env();
if metadata.is_bash_tool_skill() {
let args: Value = serde_json::from_str(arguments).context("Invalid arguments JSON")?;
let command = args
.get("command")
.and_then(|v| v.as_str())
.context("'command' is required for bash-tool skills")?;
let skill_patterns = metadata.get_bash_patterns();
let validator_patterns: Vec<skilllite_sandbox::bash_validator::BashToolPattern> =
skill_patterns
.into_iter()
.map(|p| skilllite_sandbox::bash_validator::BashToolPattern {
command_prefix: p.command_prefix,
raw_pattern: p.raw_pattern,
})
.collect();
skilllite_sandbox::bash_validator::validate_bash_command(command, &validator_patterns)
.map_err(|e| anyhow::anyhow!("Command validation failed: {}", e))?;
let effective_cwd = skilllite_core::config::PathsConfig::from_env()
.output_dir
.as_ref()
.map(std::path::PathBuf::from)
.filter(|p| p.is_dir())
.unwrap_or_else(|| workspace.to_path_buf());
execute_bash_in_skill(skill_dir, command, &env_path, &effective_cwd, workspace)
} else {
let input_json = if arguments.trim().is_empty() || arguments.trim() == "{}" {
"{}".to_string()
} else {
arguments.to_string()
};
let _: Value = serde_json::from_str(&input_json)
.map_err(|e| anyhow::anyhow!("Invalid input JSON: {}", e))?;
let effective_metadata = if let Some(ref entry) = multi_script_entry {
let mut m = metadata.clone();
m.entry_point = entry.to_string();
m
} else {
metadata
};
let runtime = skilllite_sandbox::env::builder::build_runtime_paths(&env_path);
let config = build_sandbox_config(skill_dir, &effective_metadata);
let output = skilllite_sandbox::runner::run_in_sandbox_with_limits_and_level(
skill_dir,
&runtime,
&config,
&input_json,
limits,
sandbox_level,
)?;
Ok(output)
}
}
fn build_sandbox_config(skill_dir: &Path, metadata: &SkillMetadata) -> SandboxConfig {
SandboxConfig {
name: metadata.name.clone(),
entry_point: metadata.entry_point.clone(),
language: metadata::detect_language(skill_dir, metadata),
network_enabled: metadata.network.enabled,
network_outbound: metadata.network.outbound.clone(),
uses_playwright: metadata.uses_playwright(),
}
}
fn execute_bash_in_skill(
skill_dir: &Path,
command: &str,
env_path: &Path,
cwd: &Path,
workspace: &Path,
) -> Result<String> {
use std::process::{Command, Stdio};
let command = rewrite_output_paths(command, cwd);
tracing::info!("bash_in_skill: cmd={:?} cwd={}", command, cwd.display());
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command.as_str());
cmd.current_dir(cwd);
cmd.env("SKILLLITE_WORKSPACE", workspace.as_os_str());
if let Some(ref output_dir) = skilllite_core::config::PathsConfig::from_env().output_dir {
cmd.env("SKILLLITE_OUTPUT_DIR", output_dir);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let bin_dir = if env_path.exists() {
env_path.join("node_modules").join(".bin")
} else {
skill_dir.join("node_modules").join(".bin")
};
if bin_dir.exists() {
let current_path = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", format!("{}:{}", bin_dir.display(), current_path));
}
let output = cmd
.output()
.with_context(|| format!("Failed to execute bash command: {}", command))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
tracing::info!(
"bash_in_skill: exit_code={} stdout_len={} stderr_len={}",
exit_code,
stdout.len(),
stderr.len(),
);
let mut result = String::new();
let stdout_trimmed = stdout.trim();
let stderr_trimmed = stderr.trim();
if exit_code == 0 {
if !stdout_trimmed.is_empty() {
result.push_str(stdout_trimmed);
}
if !stderr_trimmed.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&format!("[stderr]: {}", stderr_trimmed));
}
if result.is_empty() {
result.push_str("Command succeeded (exit 0)");
}
} else {
result.push_str(&format!("Command failed (exit {}):", exit_code));
if !stdout_trimmed.is_empty() {
result.push_str(&format!("\n{}", stdout_trimmed));
}
if !stderr_trimmed.is_empty() {
result.push_str(&format!("\n[stderr]: {}", stderr_trimmed));
}
}
Ok(result)
}
fn rewrite_output_paths(command: &str, output_dir: &Path) -> String {
const OUTPUT_EXTENSIONS: &[&str] = &[
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", ".html", ".htm", ".json",
".csv", ".txt", ".md", ".webm", ".mp4",
];
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.len() < 2 {
return command.to_string();
}
let mut result_parts: Vec<String> = Vec::with_capacity(parts.len());
for part in &parts {
let lower = part.to_lowercase();
let is_absolute = part.starts_with('/');
let has_env_var = part.contains('$');
let is_url = part.contains("://");
let has_output_ext = OUTPUT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
if has_output_ext && !is_absolute && !has_env_var && !is_url {
let abs = output_dir.join(part);
result_parts.push(abs.to_string_lossy().to_string());
} else {
result_parts.push(part.to_string());
}
}
result_parts.join(" ")
}