1use anyhow::{Context, Result};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::path::Path;
7
8use super::usage_stats;
9use crate::high_risk;
10use crate::types::{EventSink, ToolResult};
11use skilllite_core::skill::metadata::{self, SkillMetadata};
12use skilllite_sandbox::runner::{ResourceLimits, SandboxConfig, SandboxLevel};
13
14use super::loader::sanitize_tool_name;
15use super::security::{compute_skill_hash, run_security_scan};
16use super::LoadedSkill;
17
18pub fn execute_skill(
21 skill: &LoadedSkill,
22 tool_name: &str,
23 arguments: &str,
24 workspace: &Path,
25 event_sink: &mut dyn EventSink,
26 entry_point_override: Option<&str>,
27) -> ToolResult {
28 let result = execute_skill_inner(
29 skill,
30 tool_name,
31 arguments,
32 workspace,
33 event_sink,
34 entry_point_override,
35 );
36 match result {
37 Ok(content) => {
38 usage_stats::track_skill_execution(&skill.name, true);
39 ToolResult {
40 tool_call_id: String::new(),
41 tool_name: tool_name.to_string(),
42 content,
43 is_error: false,
44 counts_as_failure: false,
45 }
46 }
47 Err(e) => {
48 usage_stats::track_skill_execution(&skill.name, false);
49 ToolResult {
50 tool_call_id: String::new(),
51 tool_name: tool_name.to_string(),
52 content: format!("Error: {}", e),
53 is_error: true,
54 counts_as_failure: true,
55 }
56 }
57 }
58}
59
60thread_local! {
64 static CONFIRMED_SKILLS: std::cell::RefCell<HashMap<String, String>> =
65 std::cell::RefCell::new(HashMap::new());
66}
67
68fn execute_skill_inner(
69 skill: &LoadedSkill,
70 tool_name: &str,
71 arguments: &str,
72 workspace: &Path,
73 event_sink: &mut dyn EventSink,
74 entry_point_override: Option<&str>,
75) -> Result<String> {
76 let skill_dir = &skill.skill_dir;
77 let mut metadata = skill.metadata.clone();
78 if let Some(msg) = skilllite_core::skill::denylist::deny_reason_for_skill_name(&metadata.name) {
79 anyhow::bail!(msg);
80 }
81 if metadata.entry_point.is_empty() {
82 if let Some(ep) = entry_point_override {
83 if !ep.is_empty() && skill_dir.join(ep).is_file() {
84 metadata.entry_point = ep.to_string();
85 }
86 }
87 }
88
89 let multi_script_entry: Option<&String> =
93 skill.multi_script_entries.get(tool_name).or_else(|| {
94 skill
95 .multi_script_entries
96 .get(&sanitize_tool_name(tool_name))
97 });
98
99 let sandbox_level = SandboxLevel::from_env_or_cli(None);
102 if sandbox_level == SandboxLevel::Level3 {
103 let code_hash = compute_skill_hash(skill_dir, &metadata);
104
105 let already_confirmed = CONFIRMED_SKILLS.with(|cache| {
107 let cache = cache.borrow();
108 cache.get(&skill.name) == Some(&code_hash)
109 });
110
111 if !already_confirmed {
112 let scan_report = run_security_scan(skill_dir, &metadata);
114
115 let prompt = if let Some(report) = scan_report {
116 format!(
117 "Skill '{}' security scan results:\n\n{}\n\nAllow execution?",
118 skill.name, report
119 )
120 } else {
121 format!(
122 "Skill '{}' wants to execute code (sandbox level 3). Allow?",
123 skill.name
124 )
125 };
126
127 if !event_sink.on_confirmation_request(&prompt) {
128 return Ok("Execution cancelled by user.".to_string());
129 }
130
131 CONFIRMED_SKILLS.with(|cache| {
133 cache.borrow_mut().insert(skill.name.clone(), code_hash);
134 });
135 }
136 }
137
138 if high_risk::confirm_network() && metadata.network.enabled {
140 let network_cache_key = format!("{}:network", skill.name);
141 let already_network_confirmed = CONFIRMED_SKILLS.with(|cache| {
142 let cache = cache.borrow();
143 cache.get(&network_cache_key).is_some()
144 });
145 if !already_network_confirmed {
146 let msg = format!(
147 "⚠️ 网络访问确认\n\nSkill '{}' 将发起网络请求。\n\n确认执行?",
148 skill.name
149 );
150 if !event_sink.on_confirmation_request(&msg) {
151 return Ok("Execution cancelled: network skill not confirmed by user.".to_string());
152 }
153 CONFIRMED_SKILLS.with(|cache| {
154 cache
155 .borrow_mut()
156 .insert(network_cache_key, "confirmed".to_string());
157 });
158 }
159 }
160
161 let cache_dir = skilllite_core::config::CacheConfig::cache_dir();
163 let env_spec = skilllite_core::EnvSpec::from_metadata(skill_dir, &metadata);
164 let env_path = skilllite_sandbox::env::builder::ensure_environment(
165 skill_dir,
166 &env_spec,
167 cache_dir.as_deref(),
168 None,
169 None,
170 )?;
171
172 let limits = ResourceLimits::from_env();
173
174 if metadata.is_bash_tool_skill() {
175 let args: Value = serde_json::from_str(arguments).context("Invalid arguments JSON")?;
177 let command = args
178 .get("command")
179 .and_then(|v| v.as_str())
180 .context("'command' is required for bash-tool skills")?;
181
182 let skill_patterns = metadata.get_bash_patterns();
183 let validator_patterns: Vec<skilllite_sandbox::bash_validator::BashToolPattern> =
184 skill_patterns
185 .into_iter()
186 .map(|p| skilllite_sandbox::bash_validator::BashToolPattern {
187 command_prefix: p.command_prefix,
188 raw_pattern: p.raw_pattern,
189 })
190 .collect();
191 skilllite_sandbox::bash_validator::validate_bash_command(command, &validator_patterns)
192 .map_err(|e| anyhow::anyhow!("Command validation failed: {}", e))?;
193
194 let effective_cwd = skilllite_core::config::PathsConfig::from_env()
198 .output_dir
199 .as_ref()
200 .map(std::path::PathBuf::from)
201 .filter(|p| p.is_dir())
202 .unwrap_or_else(|| workspace.to_path_buf());
203
204 execute_bash_in_skill(skill_dir, command, &env_path, &effective_cwd, workspace)
205 } else {
206 let input_json = if arguments.trim().is_empty() || arguments.trim() == "{}" {
208 "{}".to_string()
209 } else {
210 arguments.to_string()
211 };
212
213 let _: Value = serde_json::from_str(&input_json)
215 .map_err(|e| anyhow::anyhow!("Invalid input JSON: {}", e))?;
216
217 let effective_metadata = if let Some(ref entry) = multi_script_entry {
218 let mut m = metadata.clone();
219 m.entry_point = entry.to_string();
220 m
221 } else {
222 metadata
223 };
224
225 let runtime = skilllite_sandbox::env::builder::build_runtime_paths(&env_path);
226 let config = build_sandbox_config(skill_dir, &effective_metadata);
227 let output = skilllite_sandbox::runner::run_in_sandbox_with_limits_and_level(
228 skill_dir,
229 &runtime,
230 &config,
231 &input_json,
232 limits,
233 sandbox_level,
234 )?;
235 Ok(output)
236 }
237}
238
239fn build_sandbox_config(skill_dir: &Path, metadata: &SkillMetadata) -> SandboxConfig {
241 SandboxConfig {
242 name: metadata.name.clone(),
243 entry_point: metadata.entry_point.clone(),
244 language: metadata::detect_language(skill_dir, metadata),
245 network_enabled: metadata.network.enabled,
246 network_outbound: metadata.network.outbound.clone(),
247 uses_playwright: metadata.uses_playwright(),
248 }
249}
250
251fn execute_bash_in_skill(
264 skill_dir: &Path,
265 command: &str,
266 env_path: &Path,
267 cwd: &Path,
268 workspace: &Path,
269) -> Result<String> {
270 use std::process::{Command, Stdio};
271
272 let command = rewrite_output_paths(command, cwd);
278
279 tracing::info!("bash_in_skill: cmd={:?} cwd={}", command, cwd.display());
280
281 let mut cmd = Command::new("sh");
282 cmd.arg("-c").arg(command.as_str());
283 cmd.current_dir(cwd);
284
285 cmd.env("SKILLLITE_WORKSPACE", workspace.as_os_str());
290 if let Some(ref output_dir) = skilllite_core::config::PathsConfig::from_env().output_dir {
291 cmd.env("SKILLLITE_OUTPUT_DIR", output_dir);
292 }
293 cmd.stdin(Stdio::null());
294 cmd.stdout(Stdio::piped());
295 cmd.stderr(Stdio::piped());
296
297 let bin_dir = if env_path.exists() {
299 env_path.join("node_modules").join(".bin")
300 } else {
301 skill_dir.join("node_modules").join(".bin")
302 };
303 if bin_dir.exists() {
304 let current_path = std::env::var("PATH").unwrap_or_default();
305 cmd.env("PATH", format!("{}:{}", bin_dir.display(), current_path));
306 }
307
308 let output = cmd
309 .output()
310 .with_context(|| format!("Failed to execute bash command: {}", command))?;
311
312 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
313 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
314 let exit_code = output.status.code().unwrap_or(-1);
315
316 tracing::info!(
317 "bash_in_skill: exit_code={} stdout_len={} stderr_len={}",
318 exit_code,
319 stdout.len(),
320 stderr.len(),
321 );
322
323 let mut result = String::new();
326 let stdout_trimmed = stdout.trim();
327 let stderr_trimmed = stderr.trim();
328
329 if exit_code == 0 {
330 if !stdout_trimmed.is_empty() {
331 result.push_str(stdout_trimmed);
332 }
333 if !stderr_trimmed.is_empty() {
334 if !result.is_empty() {
335 result.push('\n');
336 }
337 result.push_str(&format!("[stderr]: {}", stderr_trimmed));
338 }
339 if result.is_empty() {
340 result.push_str("Command succeeded (exit 0)");
341 }
342 } else {
343 result.push_str(&format!("Command failed (exit {}):", exit_code));
344 if !stdout_trimmed.is_empty() {
345 result.push_str(&format!("\n{}", stdout_trimmed));
346 }
347 if !stderr_trimmed.is_empty() {
348 result.push_str(&format!("\n[stderr]: {}", stderr_trimmed));
349 }
350 }
351
352 Ok(result)
353}
354
355fn rewrite_output_paths(command: &str, output_dir: &Path) -> String {
373 const OUTPUT_EXTENSIONS: &[&str] = &[
375 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", ".html", ".htm", ".json",
376 ".csv", ".txt", ".md", ".webm", ".mp4",
377 ];
378
379 let parts: Vec<&str> = command.split_whitespace().collect();
380 if parts.len() < 2 {
381 return command.to_string();
382 }
383
384 let mut result_parts: Vec<String> = Vec::with_capacity(parts.len());
385 for part in &parts {
386 let lower = part.to_lowercase();
387
388 let is_absolute = part.starts_with('/');
390 let has_env_var = part.contains('$');
391 let is_url = part.contains("://");
392
393 let has_output_ext = OUTPUT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext));
394
395 if has_output_ext && !is_absolute && !has_env_var && !is_url {
396 let abs = output_dir.join(part);
398 result_parts.push(abs.to_string_lossy().to_string());
399 } else {
400 result_parts.push(part.to_string());
401 }
402 }
403
404 result_parts.join(" ")
405}